Добавить sync-профиль пользователя и обход Solana RPC

This commit is contained in:
AidarKC 2026-06-25 18:46:47 +04:00
parent f3e4233285
commit 23edad416c
13 changed files with 419 additions and 41 deletions

View File

@ -2,11 +2,12 @@
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии. Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
Сейчас здесь семь методов: Сейчас здесь восемь методов:
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения; - `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети; - `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации; - `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации;
- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC;
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков; - `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог; - `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер; - `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
@ -17,6 +18,7 @@
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо; - `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла. - `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону. - `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону.
- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC.
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов. Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
@ -197,7 +199,90 @@
--- ---
## 4. `GetCallIceConfig` ## 4. `GetSyncUserProfile`
### Назначение
Запрос минимального профиля пользователя для межсерверной синхронизации.
Нужен в сценарии, когда сервер во время periodic sync увидел чужой блокчейн, которого у него локально ещё нет. Вместо обращения в Solana PDA он может запросить у партнёра:
- `login`
- `blockchainName`
- `solanaKey`
- `blockchainKey`
- `clientKey`
- `blockchainSizeLimitBytes`
После этого принимающий сервер может локально создать записи в `solana_users` и `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`.
Этот запрос доступен без авторизации и предназначен именно для server-to-server sync.
### Запрос
```json
{
"op": "GetSyncUserProfile",
"requestId": "sync-user-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ: пользователь не найден
```json
{
"op": "GetSyncUserProfile",
"requestId": "sync-user-001",
"status": 200,
"ok": true,
"payload": {
"exists": false
}
}
```
### Успешный ответ: пользователь найден
```json
{
"op": "GetSyncUserProfile",
"requestId": "sync-user-001",
"status": 200,
"ok": true,
"payload": {
"exists": true,
"login": "alice",
"blockchainName": "alice-001",
"solanaKey": "BASE64_32",
"blockchainKey": "BASE64_32",
"clientKey": "BASE64_32",
"blockchainSizeLimitBytes": 100000
}
}
```
### Поля ответа
- `exists` — найден ли пользователь на сервере-партнёре.
- `login` — канонический login из БД сервера-партнёра.
- `blockchainName` — имя основной цепочки пользователя.
- `solanaKey` — публичный ключ логина.
- `blockchainKey` — публичный ключ блокчейна.
- `clientKey` — публичный клиентский ключ, который в текущей модели используется при создании локальной записи.
- `blockchainSizeLimitBytes` — лимит размера файла блокчейна, который будет записан в локальный `blockchain_state`.
### Специфические коды ошибок `GetSyncUserProfile`
- `400 / BAD_FIELDS` — пустой или некорректный `login`.
- `404 / BLOCKCHAIN_STATE_NOT_FOUND` — пользователь найден, но на сервере-партнёре отсутствует `blockchain_state` для его цепочки.
- При непредвиденной ошибке сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 5. `GetCallIceConfig`
Доступно только после успешной авторизации. Доступно только после успешной авторизации.
@ -247,7 +332,7 @@
--- ---
## 5. `ClientErrorLog` ## 6. `ClientErrorLog`
### Запрос ### Запрос
@ -294,7 +379,7 @@
--- ---
## 6. `ClientDebugLog` ## 7. `ClientDebugLog`
### Запрос ### Запрос
@ -332,7 +417,7 @@
--- ---
## 7. `CallDeliveryReport` ## 8. `CallDeliveryReport`
### Запрос ### Запрос

View File

@ -36,6 +36,7 @@
| `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов | | `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
| `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации |
| `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков | | `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков |
| `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки | | `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки |
| `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог | | `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог |

View File

@ -39,33 +39,94 @@
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу). - Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу. - Дедупликация по глобальному номеру блока и хэшу.
## 4. Протокол синхронизации (целевой, не реализован) ## 4. Текущая реализованная схема
### 4.1 Межсерверное соединение На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
### 4.1 Что уже сделано
1. При старте сервер читает свой `server.SHiNE.login`.
2. По этому логину он загружает из Solana свою server PDA.
3. Из неё вытаскивает список `sync_servers`.
4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально:
- `login`
- `server_address`
5. После этого:
- новые локальные `AddBlock` рассылаются партнёрам в фоне;
- при старте запускается periodic sync;
- periodic sync повторяется каждые `12` часов после старта.
### 4.2 Какие server-to-server API уже используются
- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра;
- `GetBlockchainBlock` — чтение одного конкретного блока партнёра;
- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания `solana_users + blockchain_state` без обращения в Solana RPC.
### 4.3 Как сейчас работает periodic sync
Для каждого сервера из локальной таблицы `sync_servers`:
1. запрашивается `ListBlockchainHeads`;
2. для каждой удалённой цепочки сравниваются:
- `lastBlockNumber`
- `lastBlockHash`
- локальное состояние;
3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`;
4. каждый скачанный блок локально применяется через существующий `AddBlock`;
5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальный `solana_users + blockchain_state`.
### 4.4 Зачем понадобился `GetSyncUserProfile`
Изначально подготовка локальной цепочки делалась через Solana:
- из `blockchainName` извлекался `login`;
- сервер вызывал import пользователя из Solana PDA;
- по данным PDA локально создавались `solana_users + blockchain_state`.
На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`.
Поэтому добавлен отдельный обходной режим:
- настройка `sync.importUserProfileFromPartner.enabled=true`
- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync;
- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра.
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
### 4.5 Что делает настройка `sync.importUserProfileFromPartner.enabled`
- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`.
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Целевой протокол следующего этапа
### 5.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом. - Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA. - Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA). - Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff. - При разрыве — переподключение с экспоненциальным backoff.
### 4.2 Доставка новых данных (push) ### 5.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам. - При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. - Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
### 4.3 Начальная синхронизация (backfill) ### 5.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния: - При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ. последний глобальный номер блока, последний известный DM-ключ.
- Сервер с более полной историей досылает недостающее партнёру. - Сервер с более полной историей досылает недостающее партнёру.
### 4.4 Разрешение конфликтов ### 5.4 Разрешение конфликтов
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. - Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`). Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
- DM: конфликтов нет, `message_key` уникален. - DM: конфликтов нет, `message_key` уникален.
## 5. Маршрутизация DM между серверами ## 6. Маршрутизация DM между серверами
При отправке DM от пользователя A к пользователю B: При отправке DM от пользователя A к пользователю B:
@ -78,23 +139,30 @@
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения). Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
## 6. Безопасность ## 7. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. - Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа). - Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. - При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
## 7. Статус реализации ## 8. Статус реализации
| Компонент | Статус | | Компонент | Статус |
|-----------|--------| |-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано | | Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | ✅ Реализовано | | Чтение `sync_servers` из PDA | ✅ Реализовано |
| Межсерверный WebSocket-канал | Нужна реализация | | Локальная таблица `sync_servers` | ✅ Реализовано |
| Публичный `ListBlockchainHeads` | ✅ Реализовано |
| Публичный `GetBlockchainBlock` | ✅ Реализовано |
| Публичный `GetSyncUserProfile` | ✅ Реализовано |
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация | | Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | | Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
| Backfill при первом подключении | Нужна реализация | | Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | Нужна реализация |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) | | Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
Текущая версия сервера работает без межсерверной синхронизации. Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами.
Синхронизация — задача следующего этапа разработки. Не реализованы ещё DM-sync, постоянные server-to-server соединения и автоматическое исправление рассинхрона цепочек.

View File

@ -1,21 +1,28 @@
# Периодическая межсерверная синхронизация блокчейнов # Периодическая межсерверная синхронизация блокчейнов
- Краткое описание: - Краткое описание:
- При старте сервер подтягивает `sync_servers` из server PDA и сохраняет адреса партнёров в локальную таблицу.
- После успешного локального `AddBlock` работает фоновая one-shot отправка нового блока партнёрам.
- Добавлен публичный `GetBlockchainBlock` для чтения одного блока. - Добавлен публичный `GetBlockchainBlock` для чтения одного блока.
- Добавлен публичный `GetSyncUserProfile` для подготовки отсутствующей локальной цепочки без прямого Solana RPC.
- Добавлен плановый sync блокчейнов при старте сервера и затем каждые `12` часов. - Добавлен плановый sync блокчейнов при старте сервера и затем каждые `12` часов.
- Синхронизация пока умеет только докачивать отсутствующий хвост цепочки. - Синхронизация пока умеет только докачивать отсутствующий хвост цепочки.
- Добавлена настройка `sync.importUserProfileFromPartner.enabled`, которая включает создание локального `solana_users + blockchain_state` по ответу сервера-партнёра вместо Solana PDA.
- Случай рассинхрона цепочек пока не исправляется автоматически: он только логируется как не реализованный сценарий. - Случай рассинхрона цепочек пока не исправляется автоматически: он только логируется как не реализованный сценарий.
- Что именно проверять: - Что именно проверять:
- После старта сервера в логах появляется запуск периодического sync. - После старта сервера в логах появляется запуск периодического sync.
- Сервер может запросить у партнёра `ListBlockchainHeads`. - Сервер может запросить у партнёра `ListBlockchainHeads`.
- Сервер может запросить у партнёра `GetSyncUserProfile`, если включён `sync.importUserProfileFromPartner.enabled=true`.
- Сервер может запросить у партнёра `GetBlockchainBlock` и локально применить блок через существующий `AddBlock`. - Сервер может запросить у партнёра `GetBlockchainBlock` и локально применить блок через существующий `AddBlock`.
- На чистом тестовом сервере после удаления БД и файлов блокчейнов сервер сам подтягивает блоки при старте. - На чистом тестовом сервере после удаления БД и файлов блокчейнов сервер сам подтягивает блоки при старте.
- При включённом режиме обхода Solana сервер восстанавливает локальные цепочки без запросов в Solana RPC.
- После первичного старта новые блоки продолжают догоняться без ручного вмешательства. - После первичного старта новые блоки продолжают догоняться без ручного вмешательства.
- При рассинхроне цепочек в логах появляется явное сообщение, что reconciliation пока не реализован. - При рассинхроне цепочек в логах появляется явное сообщение, что reconciliation пока не реализован.
- Ожидаемый результат: - Ожидаемый результат:
- Чистый сервер после старта сам восстанавливает локальные цепочки от партнёра синхронизации. - Чистый сервер после старта сам восстанавливает локальные цепочки от партнёра синхронизации.
- В режиме обхода Solana чистый сервер не упирается в `Solana RPC 429` при создании локальных chain-state для уже существующих на партнёре пользователей.
- Периодический sync не мешает обычной работе сервера и не ломает локальный `AddBlock`. - Периодический sync не мешает обычной работе сервера и не ломает локальный `AddBlock`.
- Нереализованный случай рассинхрона не приводит к падению сервера и явно отражается в логах. - Нереализованный случай рассинхрона не приводит к падению сервера и явно отражается в логах.

View File

@ -106,6 +106,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque
// --- NEW: Ping --- // --- NEW: Ping ---
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_GetSyncUserProfile_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
@ -116,6 +117,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
@ -199,6 +201,7 @@ public final class JsonHandlerRegistry {
Map.entry("Ping", new Net_Ping_Handler()), Map.entry("Ping", new Net_Ping_Handler()),
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()), Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
Map.entry("GetSyncUserProfile", new Net_GetSyncUserProfile_Handler()),
Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()), Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()),
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()), Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()),
Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()), Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()),
@ -276,6 +279,7 @@ public final class JsonHandlerRegistry {
Map.entry("Ping", Net_Ping_Request.class), Map.entry("Ping", Net_Ping_Request.class),
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class), Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class), Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
Map.entry("GetSyncUserProfile", Net_GetSyncUserProfile_Request.class),
Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class), Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class),
Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class), Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class),
Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class), Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class),

View File

@ -0,0 +1,86 @@
package server.logic.ws_protocol.JSON.handlers.system;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserEntry;
/**
* GetSyncUserProfile server-to-server профиль пользователя для межсерверной синхронизации.
* Нужен, чтобы принимающий сервер мог создать локальные solana_users + blockchain_state
* без прямого запроса в Solana RPC.
*/
public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetSyncUserProfile_Handler.class);
private final SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetSyncUserProfile_Request req = (Net_GetSyncUserProfile_Request) baseRequest;
String login = req.getLogin() == null ? "" : req.getLogin().trim();
if (login.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login"
);
}
try {
SolanaUserEntry user = usersDAO.getByLogin(login);
Net_GetSyncUserProfile_Response resp = new Net_GetSyncUserProfile_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
if (user == null) {
resp.setExists(false);
return resp;
}
BlockchainStateEntry state = stateDAO.getByBlockchainName(user.getBlockchainName());
if (state == null) {
log.warn("GetSyncUserProfile: blockchain_state not found for login={} blockchainName={}",
user.getLogin(), user.getBlockchainName());
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.NOT_FOUND,
"BLOCKCHAIN_STATE_NOT_FOUND",
"Состояние блокчейна пользователя не найдено"
);
}
resp.setExists(true);
resp.setLogin(user.getLogin());
resp.setBlockchainName(user.getBlockchainName());
resp.setSolanaKey(user.getSolanaKey());
resp.setBlockchainKey(user.getBlockchainKey());
resp.setClientKey(user.getClientKey());
resp.setBlockchainSizeLimitBytes(state.getSizeLimit());
return resp;
} catch (Exception e) {
log.error("❌ Internal error GetSyncUserProfile login={}", login, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetSyncUserProfile", e)
);
}
}
}

View File

@ -0,0 +1,14 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос межсерверного профиля пользователя для синхронизации.
*/
public class Net_GetSyncUserProfile_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}

View File

@ -0,0 +1,38 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ межсерверного профиля пользователя для синхронизации.
*/
public class Net_GetSyncUserProfile_Response extends Net_Response {
private Boolean exists;
private String login;
private String blockchainName;
private String solanaKey;
private String blockchainKey;
private String clientKey;
private Long blockchainSizeLimitBytes;
public Boolean getExists() { return exists; }
public void setExists(Boolean exists) { this.exists = exists; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public String getSolanaKey() { return solanaKey; }
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getClientKey() { return clientKey; }
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public Long getBlockchainSizeLimitBytes() { return blockchainSizeLimitBytes; }
public void setBlockchainSizeLimitBytes(Long blockchainSizeLimitBytes) { this.blockchainSizeLimitBytes = blockchainSizeLimitBytes; }
}

View File

@ -61,6 +61,41 @@ public final class RemoteBlockchainSyncClient {
return result; return result;
} }
public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception {
String safeLogin = MAPPER.writeValueAsString(login);
JsonNode response = send(serverAddressRaw, """
{
"op":"GetSyncUserProfile",
"requestId":%s,
"payload":{
"login":%s
}
}
""".formatted("%s", safeLogin));
int status = response.path("status").asInt(500);
if (status == 404) {
return null;
}
if (status < 200 || status >= 300) {
throw new IllegalStateException("GetSyncUserProfile failed: status=" + status + " code=" + errorCode(response));
}
JsonNode payload = response.path("payload");
if (!payload.path("exists").asBoolean(false)) {
return null;
}
return new RemoteSyncUserProfile(
payload.path("login").asText(login),
payload.path("blockchainName").asText(""),
payload.path("solanaKey").asText(""),
payload.path("blockchainKey").asText(""),
payload.path("clientKey").asText(""),
payload.path("blockchainSizeLimitBytes").asLong(0L)
);
}
public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception { public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(serverAddressRaw, """
@ -176,6 +211,15 @@ public final class RemoteBlockchainSyncClient {
String blockBytesB64 String blockBytesB64
) {} ) {}
public record RemoteSyncUserProfile(
String login,
String blockchainName,
String solanaKey,
String blockchainKey,
String clientKey,
long blockchainSizeLimitBytes
) {}
private static final class SyncWsListener implements WebSocket.Listener { private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture; private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch; private final CountDownLatch openLatch;

View File

@ -9,9 +9,11 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.SyncServersDAO; import shine.db.dao.SyncServersDAO;
import shine.db.dao.UserCreateDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SyncServerEntry; import shine.db.entities.SyncServerEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import utils.config.AppConfig;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -44,6 +46,8 @@ public final class PeriodicBlockchainSyncService {
private static final Net_AddBlock_Handler ADD_BLOCK_HANDLER = new Net_AddBlock_Handler(); private static final Net_AddBlock_Handler ADD_BLOCK_HANDLER = new Net_AddBlock_Handler();
private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance(); private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance();
private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance(); private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance();
private static final UserCreateDAO USER_CREATE_DAO = UserCreateDAO.getInstance();
private static final String CONFIG_IMPORT_PROFILE_FROM_PARTNER = "sync.importUserProfileFromPartner.enabled";
private PeriodicBlockchainSyncService() {} private PeriodicBlockchainSyncService() {}
@ -140,7 +144,7 @@ public final class PeriodicBlockchainSyncService {
String localHash String localHash
) throws Exception { ) throws Exception {
String partnerLogin = normalize(partner.getLogin()); String partnerLogin = normalize(partner.getLogin());
if (!ensureLocalChainExists(remoteHead.blockchainName())) { if (!ensureLocalChainExists(partner, remoteHead.blockchainName())) {
log.warn("Periodic blockchain sync: cannot prepare local chain. partner={} blockchainName={}", log.warn("Periodic blockchain sync: cannot prepare local chain. partner={} blockchainName={}",
partnerLogin, remoteHead.blockchainName()); partnerLogin, remoteHead.blockchainName());
return; return;
@ -198,7 +202,7 @@ public final class PeriodicBlockchainSyncService {
return new LocalAddBlockApplyResult(false, error.getCode(), error.getMessage(), ""); return new LocalAddBlockApplyResult(false, error.getCode(), error.getMessage(), "");
} }
private static boolean ensureLocalChainExists(String blockchainName) { private static boolean ensureLocalChainExists(SyncServerEntry partner, String blockchainName) {
try { try {
if (STATE_DAO.getByBlockchainName(blockchainName) != null) { if (STATE_DAO.getByBlockchainName(blockchainName) != null) {
return true; return true;
@ -207,6 +211,9 @@ public final class PeriodicBlockchainSyncService {
if (login == null || login.isBlank()) { if (login == null || login.isBlank()) {
return false; return false;
} }
if (AppConfig.getInstance().getBoolean(CONFIG_IMPORT_PROFILE_FROM_PARTNER, false)) {
return importUserProfileFromPartner(partner, login);
}
SolanaUserPdaImportService.findOrImportByLogin(login); SolanaUserPdaImportService.findOrImportByLogin(login);
return STATE_DAO.getByBlockchainName(blockchainName) != null; return STATE_DAO.getByBlockchainName(blockchainName) != null;
} catch (Exception e) { } catch (Exception e) {
@ -216,6 +223,37 @@ public final class PeriodicBlockchainSyncService {
} }
} }
private static boolean importUserProfileFromPartner(SyncServerEntry partner, String login) throws Exception {
if (partner == null || partner.getServerAddress() == null || partner.getServerAddress().isBlank()) {
return false;
}
RemoteBlockchainSyncClient.RemoteSyncUserProfile profile =
REMOTE.getSyncUserProfile(partner.getServerAddress(), login);
if (profile == null) {
log.warn("Periodic blockchain sync: partner has no sync profile for login={} partner={}",
login, normalize(partner.getLogin()));
return false;
}
long now = System.currentTimeMillis();
long sizeLimit = profile.blockchainSizeLimitBytes() > 0 ? profile.blockchainSizeLimitBytes() : 100_000L;
boolean inserted = USER_CREATE_DAO.insertUserWithBlockchain(
profile.login(),
profile.blockchainName(),
profile.solanaKey(),
profile.blockchainKey(),
profile.clientKey(),
sizeLimit,
now
);
if (!inserted) {
return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null;
}
return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null;
}
private static String normalize(String value) { private static String normalize(String value) {
if (value == null) return null; if (value == null) return null;
String s = value.trim().toLowerCase(Locale.ROOT); String s = value.trim().toLowerCase(Locale.ROOT);

View File

@ -2,6 +2,19 @@ server.1port=7070
db.path=data/shine.sqlite db.path=data/shine.sqlite
server.SHiNE.login=shineupme server.SHiNE.login=shineupme
# ------------------------------------------------------------
# Межсерверная синхронизация: как создавать локальную запись пользователя,
# если во время sync пришла чужая цепочка, а у нас такого login ещё нет.
# false - брать профиль пользователя напрямую из Solana PDA (обычный режим).
# true - не ходить в Solana RPC, а запрашивать у сервера-партнёра специальный
# sync-профиль пользователя и по нему локально создавать
# solana_users + blockchain_state.
# Эта настройка нужна как временный обход лимитов Solana RPC (например 429),
# чтобы чистый сервер мог восстановить цепочки от партнёра без зависимости
# от внешнего Solana endpoint.
# ------------------------------------------------------------
sync.importUserProfileFromPartner.enabled=false
# ------------------------------------------------------------ # ------------------------------------------------------------
# Server public info # Server public info
# Эти поля используются JSON-операцией GetServerInfo. # Эти поля используются JSON-операцией GetServerInfo.

View File

@ -1,2 +1,2 @@
client.version=1.2.272 client.version=1.2.273
server.version=1.2.252 server.version=1.2.253

View File

@ -1,7 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
PROD_HOST="${PROD_HOST:-player@shineup.me}"
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}" TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}"
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}" REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
@ -11,8 +10,6 @@ REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}" REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}"
REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}" REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}"
LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}" LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}"
PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}"
PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}"
TMP_DIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
cleanup() { cleanup() {
@ -25,25 +22,10 @@ if [[ ! -f "$LOCAL_JAR" ]]; then
exit 1 exit 1
fi fi
ssh -o BatchMode=yes -o ConnectTimeout=20 "$PROD_HOST" "echo SSH OK" >/dev/null
ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null
ssh "$TARGET_HOST" "sudo -n true" ssh "$TARGET_HOST" "sudo -n true"
ssh "$TARGET_HOST" "java -version >/dev/null 2>&1" ssh "$TARGET_HOST" "java -version >/dev/null 2>&1"
mkdir -p "$TMP_DIR/data"
rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/"
rsync -az "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties"
if grep -q '^server\.ui\.indexPath=' "$TMP_DIR/application.properties"; then
perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties"
else
printf '\nserver.ui.indexPath=/home/player/SHiNE/shine-ui/index.html\n' >>"$TMP_DIR/application.properties"
fi
if grep -q '^server\.SHiNE\.login=' "$TMP_DIR/application.properties"; then
perl -0pi -e 's@^server\.SHiNE\.login=.*$@server.SHiNE.login=tshineupme@m' "$TMP_DIR/application.properties"
else
printf '\nserver.SHiNE.login=tshineupme\n' >>"$TMP_DIR/application.properties"
fi
cat >"$TMP_DIR/shine-server.service" <<EOF cat >"$TMP_DIR/shine-server.service" <<EOF
[Unit] [Unit]
Description=SHiNE Server Description=SHiNE Server
@ -68,9 +50,7 @@ TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE
bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh" bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh"
ssh "$TARGET_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR'" ssh "$TARGET_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR'"
rsync -az --delete "$TMP_DIR/data/" "$TARGET_HOST:$REMOTE_DATA_DIR/"
rsync -az --timeout=120 "$LOCAL_JAR" "$TARGET_HOST:$REMOTE_SERVER_DIR/shine-server.jar" rsync -az --timeout=120 "$LOCAL_JAR" "$TARGET_HOST:$REMOTE_SERVER_DIR/shine-server.jar"
rsync -az "$TMP_DIR/application.properties" "$TARGET_HOST:$REMOTE_SERVER_DIR/application.properties"
rsync -az "$TMP_DIR/shine-server.service" "$TARGET_HOST:/tmp/shine-server.service" rsync -az "$TMP_DIR/shine-server.service" "$TARGET_HOST:/tmp/shine-server.service"
ssh "$TARGET_HOST" "set -euo pipefail; \ ssh "$TARGET_HOST" "set -euo pipefail; \