Compare commits
15 Commits
112ab4d5d5
...
c397c28acb
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
c397c28acb | ||
|
|
c93cc6c522 | ||
|
|
0cdcc77606 | ||
|
|
87eec7e5c9 | ||
|
|
44a1ba01f3 | ||
|
|
d49661fa29 | ||
|
|
71fdee0cfd | ||
|
|
1ced351ea2 | ||
|
|
c048347f2e | ||
|
|
be4f76834a | ||
|
|
23edad416c | ||
|
|
f3e4233285 | ||
|
|
84e0f039cb | ||
|
|
1f8b20a7d1 | ||
|
|
f0e1ab3af8 |
@ -74,11 +74,6 @@
|
|||||||
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
|
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
|
||||||
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
||||||
|
|
||||||
## Известная проблема (временная пометка)
|
|
||||||
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
|
|
||||||
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
|
|
||||||
- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя.
|
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
||||||
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
|
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
|
||||||
@ -90,13 +85,13 @@
|
|||||||
## Deploy
|
## Deploy
|
||||||
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
||||||
- Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`).
|
- Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`).
|
||||||
- Основной test-хост SHiNE: `player@193.8.215.70` (`test2.shineup.me`).
|
- Основной test-хост SHiNE: `player@193.8.215.70` (`t.shineup.me`).
|
||||||
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
|
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
|
||||||
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
||||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||||
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||||
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
|
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
|
||||||
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `test2.shineup.me`.
|
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `t.shineup.me`.
|
||||||
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
|
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
|
||||||
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
|
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
|
||||||
- Production server deploy: `./gradlew deployServerProduction`.
|
- Production server deploy: `./gradlew deployServerProduction`.
|
||||||
|
|||||||
@ -1,255 +0,0 @@
|
|||||||
# DAO_запуск
|
|
||||||
|
|
||||||
Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE.
|
|
||||||
|
|
||||||
Логика документа:
|
|
||||||
|
|
||||||
- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде;
|
|
||||||
- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата.
|
|
||||||
|
|
||||||
Базовая среда первого прохода:
|
|
||||||
|
|
||||||
- сеть `Solana devnet`;
|
|
||||||
- модель синхронизации: `server-to-server`;
|
|
||||||
- `Solana + Arweave` используются как якорь и архив;
|
|
||||||
- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги.
|
|
||||||
|
|
||||||
## Краткий вывод
|
|
||||||
|
|
||||||
Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как:
|
|
||||||
|
|
||||||
- можно развернуть и проверить базовый DAO-контур;
|
|
||||||
- можно зарегистрировать пользователей и ключевые сущности;
|
|
||||||
- можно провести тестовую покупку билета через smart contract;
|
|
||||||
- можно завести тестовый денежный поток в программы, управляемые DAO;
|
|
||||||
- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой.
|
|
||||||
|
|
||||||
Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять.
|
|
||||||
|
|
||||||
## Этап1
|
|
||||||
|
|
||||||
Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры.
|
|
||||||
|
|
||||||
### 1. Переписать и стабилизировать регистрацию пользователей без Anchor
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния;
|
|
||||||
- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI;
|
|
||||||
- проверить создание и чтение `user_pda`;
|
|
||||||
- проверить update пользовательской записи и связанные экономические параметры;
|
|
||||||
- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии.
|
|
||||||
|
|
||||||
### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- руками прогнать регистрацию нового пользователя;
|
|
||||||
- руками прогнать создание и update server PDA там, где это требуется текущему сценарию;
|
|
||||||
- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов;
|
|
||||||
- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым.
|
|
||||||
|
|
||||||
### 3. Создать стандартный DAO smart contract / governance-контур
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE;
|
|
||||||
- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах;
|
|
||||||
- подготовить тестовую DAO-структуру в `devnet`.
|
|
||||||
|
|
||||||
Минимум для первого запуска:
|
|
||||||
|
|
||||||
- DAO существует как управляемая сущность;
|
|
||||||
- DAO может владеть или контролировать ключевые права управления денежными программами;
|
|
||||||
- есть понятный путь, как DAO влияет на доходные программы SHiNE.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы.
|
|
||||||
|
|
||||||
### 4. Доработать смарт-контракт выплат с третьей очередью
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- добавить в `shine_payments` третью очередь, о которой уже принято решение;
|
|
||||||
- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления;
|
|
||||||
- убедиться, что логика очередей соответствует ожидаемой экономике проекта.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики.
|
|
||||||
|
|
||||||
### 5. Сделать UI для покупки билетов и просмотра очереди
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- добавить UI-сценарий покупки билетов через smart contract;
|
|
||||||
- показать пользователю, сколько перед ним человек в очереди;
|
|
||||||
- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция;
|
|
||||||
- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки.
|
|
||||||
|
|
||||||
### 6. Реализовать базовую синхронизацию серверов
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- сделать обмен состоянием между серверами по модели `server-to-server`;
|
|
||||||
- определить минимальный набор данных, который обязан синхронизироваться;
|
|
||||||
- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой;
|
|
||||||
- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую;
|
|
||||||
- это напрямую связано с идеей, что любой сможет поднять свой сервер.
|
|
||||||
|
|
||||||
### 7. Подготовить базовый сценарий архивирования и восстановления
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`;
|
|
||||||
- определить минимальный сценарий восстановления блоков или состояния из архивного слоя;
|
|
||||||
- подтвердить, что новый сервер может получить достаточно данных для старта.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры.
|
|
||||||
|
|
||||||
## Этап2
|
|
||||||
|
|
||||||
Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`.
|
|
||||||
|
|
||||||
### 1. Смена ключей цифровой подписи
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- продумать и реализовать смену `root key`, `client key`, `blockchain key`;
|
|
||||||
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
|
|
||||||
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- для production это очень важно;
|
|
||||||
- для первого тестового запуска можно временно использовать фиксированный набор ключей.
|
|
||||||
|
|
||||||
### 2. Полная повторная перепроверка всех сценариев
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`;
|
|
||||||
- оформить итоговый чек-лист ручной проверки;
|
|
||||||
- отдельно проверить пограничные сценарии и восстановление после ошибок.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это обязательный шаг перед переходом от "собрали" к "доверяем".
|
|
||||||
|
|
||||||
### 3. Устройство на ESP32 как homeserver с ключами
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- дописать прошивку, чтобы устройство могло выступать homeserver с ключами;
|
|
||||||
- дать ему возможность регистрироваться и подключаться к серверу;
|
|
||||||
- определить, какие операции устройство подписывает и где хранит ключевой материал.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск.
|
|
||||||
|
|
||||||
### 4. Логин и подпись через коробочки / устройства
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство;
|
|
||||||
- определить, как это встраивается в регистрацию DAO и подтверждение действий;
|
|
||||||
- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это следующий уровень безопасности и UX, но не минимальный блокер первого старта.
|
|
||||||
|
|
||||||
### 5. Создание тестового DAO с использованием устройств подписи
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием;
|
|
||||||
- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это проверка усиленной модели, а не базового старта.
|
|
||||||
|
|
||||||
### 6. Расписание синхронизации серверов
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- определить периодичность и правила фоновой синхронизации;
|
|
||||||
- продумать ручной и автоматический режим;
|
|
||||||
- решить, как часто публиковать архивные снимки и якоря.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной.
|
|
||||||
|
|
||||||
### 7. Полное восстановление блоков из Solana/Arweave
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- довести процедуру восстановления до сценария "любой может поднять свой сервер";
|
|
||||||
- определить минимальный bootstrap-набор;
|
|
||||||
- проверить восстановление на чистом окружении.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации.
|
|
||||||
|
|
||||||
## Что блокирует первый запуск сильнее всего
|
|
||||||
|
|
||||||
Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так:
|
|
||||||
|
|
||||||
1. pure Rust регистрация пользователей и ручная проверка сценария;
|
|
||||||
2. DAO/gov-контур и его права управления;
|
|
||||||
3. доработка выплат с третьей очередью;
|
|
||||||
4. покупка билетов через smart contract и UI-проверка очереди;
|
|
||||||
5. межсерверная синхронизация;
|
|
||||||
6. архивирование в `Arweave` с якорем в `Solana`;
|
|
||||||
7. минимальное восстановление состояния новым сервером.
|
|
||||||
|
|
||||||
## Что уже частично похоже на готовое
|
|
||||||
|
|
||||||
По текущим документам и следам в проекте уже видно, что:
|
|
||||||
|
|
||||||
- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан;
|
|
||||||
- архитектура DAO, `shine_users` и `shine_payments` уже описана;
|
|
||||||
- часть Solana-структуры и PDA-форматов уже формализована;
|
|
||||||
- тема ESP32 уже отдельно присутствует в проекте как направление.
|
|
||||||
|
|
||||||
Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах.
|
|
||||||
|
|
||||||
## Вопросы, которые всё ещё стоит уточнить
|
|
||||||
|
|
||||||
1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков?
|
|
||||||
2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики?
|
|
||||||
3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов?
|
|
||||||
4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии?
|
|
||||||
5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов?
|
|
||||||
|
|
||||||
## Рекомендуемый следующий практический шаг
|
|
||||||
|
|
||||||
Если идти без распыления, то следующим рабочим фокусом стоит считать:
|
|
||||||
|
|
||||||
1. закрыть ручную проверку pure Rust регистрации;
|
|
||||||
2. после этого формализовать минимальный DAO-контур;
|
|
||||||
3. затем переходить к третьей очереди выплат и к UI покупки билетов;
|
|
||||||
4. после этого делать синхронизацию, архив и восстановление.
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!!
|
|
||||||
|
|
||||||
ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР
|
|
||||||
* [2] type - тип соощения
|
|
||||||
* [2] Sиbtype - субтип сообщения
|
|
||||||
* [2] version - версия формата соощения
|
|
||||||
|
|
||||||
А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ
|
|
||||||
|
|
||||||
|
|
||||||
ПЕРЕДЕЛАЕМ БД
|
|
||||||
|
|
||||||
1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО)
|
|
||||||
(НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО)
|
|
||||||
А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ)
|
|
||||||
|
|
||||||
В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА)
|
|
||||||
|
|
||||||
А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО
|
|
||||||
|
|
||||||
и дальше:
|
|
||||||
ЗДЕЛАТЬ ТРИ ЗАПРОСА:
|
|
||||||
СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ
|
|
||||||
ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА-
|
|
||||||
А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА
|
|
||||||
|
|
||||||
СПИСОК СООБЩЕНИЙ В КАНАЛЕ
|
|
||||||
|
|
||||||
ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО)
|
|
||||||
|
|
||||||
(НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ)
|
|
||||||
|
|
||||||
|
|
||||||
И ПОМЯТКА
|
|
||||||
ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ
|
|
||||||
ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ
|
|
||||||
|
|
||||||
ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА
|
|
||||||
(СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО)
|
|
||||||
|
|
||||||
ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё.
|
|
||||||
поэтому просто на каждую реакцию добавиться убрать эту ракцию .
|
|
||||||
- это просто
|
|
||||||
|
|
||||||
сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый!
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
сделать сессион пасворд тоже ключём подписи устройства!!
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
Перечень библиотек и их краткое описание
|
|
||||||
|
|
||||||
shine-server-log
|
|
||||||
Статический “сиренный” метод для максимально заметного критического лога администратору
|
|
||||||
|
|
||||||
shine-server-config
|
|
||||||
Минимальный конфиг-лоадер, который один раз читает application.properties и даёт доступ к параметрам.
|
|
||||||
|
|
||||||
shine-server-geo
|
|
||||||
Утилиты, которые вытаскивают IP/язык/UA из Jetty WebSocket и (опционально) резолвят гео по IP с кэшем в БД.
|
|
||||||
|
|
||||||
shine-server-crypto
|
|
||||||
Базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test.
|
|
||||||
|
|
||||||
shine-server-bd
|
|
||||||
Библиотека реалезующая всю работу с БД:
|
|
||||||
|
|
||||||
shine-server-blockchain
|
|
||||||
Библиотека, которая задаёт единый бинарный формат блоков (RAW+signature+hash), парсит/валидирует “тело” блока по type/version, и проверяет целостность/подпись цепочки через SHA-256 + Ed25519 с привязкой к login и предыдущим хэшам.
|
|
||||||
|
|
||||||
shine-server-protocol
|
|
||||||
Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами.
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
SHiNE — структура БД (актуальная версия)
|
|
||||||
|
|
||||||
Перечень таблиц и назначение
|
|
||||||
|
|
||||||
solana_users
|
|
||||||
Справочник пользователей: логин + ключ устройства + (опционально) Solana-ключ.
|
|
||||||
Базовая таблица, используется как FK почти везде.
|
|
||||||
|
|
||||||
active_sessions
|
|
||||||
Активные сессии авторизации/работы клиента: секреты, тайминги, WebPush-данные, IP и информация о клиенте.
|
|
||||||
|
|
||||||
users_params
|
|
||||||
Хранилище актуальных параметров пользователя.
|
|
||||||
Для каждой пары (login, param) хранится только самая новая версия по time_ms.
|
|
||||||
|
|
||||||
ip_geo_cache
|
|
||||||
Кеш геолокации по IP для снижения нагрузки на внешние сервисы.
|
|
||||||
|
|
||||||
blockchain_state
|
|
||||||
Агрегированное состояние блокчейна по blockchain_name:
|
|
||||||
лимиты, текущий размер, последний глобальный блок и состояние линий 0..7.
|
|
||||||
|
|
||||||
blocks
|
|
||||||
Журнал всех блоков и сообщений.
|
|
||||||
Содержит историю событий: тексты, реакции, ответы, связи.
|
|
||||||
PRIMARY KEY намеренно отсутствует.
|
|
||||||
|
|
||||||
connections_state ⭐
|
|
||||||
Актуальное состояние связей между пользователями
|
|
||||||
(друг / контакт / подписка).
|
|
||||||
Обновляется автоматически на основе событий из blocks.
|
|
||||||
|
|
||||||
message_stats ⭐
|
|
||||||
Агрегированные счётчики лайков и ответов на конкретные сообщения.
|
|
||||||
Поддерживается триггерами из blocks.
|
|
||||||
|
|
||||||
Таблицы подробно
|
|
||||||
|
|
||||||
solana_users
|
|
||||||
login — TEXT PK — уникальный логин пользователя
|
|
||||||
client_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
|
|
||||||
solana_key — TEXT NULL — публичный ключ Solana-аккаунта
|
|
||||||
|
|
||||||
active_sessions
|
|
||||||
session_id — TEXT PK — идентификатор сессии
|
|
||||||
login — TEXT NOT NULL, FK → solana_users(login)
|
|
||||||
session_pwd — TEXT NOT NULL — секрет сессии
|
|
||||||
storage_pwd — TEXT NOT NULL — секрет storage
|
|
||||||
session_created_at_ms — INTEGER NOT NULL
|
|
||||||
last_authirificated_at_ms — INTEGER NOT NULL
|
|
||||||
push_endpoint — TEXT NULL
|
|
||||||
push_p256dh_key — TEXT NULL
|
|
||||||
push_auth_key — TEXT NULL
|
|
||||||
client_ip — TEXT NULL
|
|
||||||
client_info_from_client — TEXT NULL
|
|
||||||
client_info_from_request — TEXT NULL
|
|
||||||
user_language — TEXT NULL
|
|
||||||
|
|
||||||
users_params
|
|
||||||
login — TEXT NOT NULL, FK → solana_users(login)
|
|
||||||
param — TEXT NOT NULL
|
|
||||||
time_ms — INTEGER NOT NULL
|
|
||||||
value — TEXT NOT NULL
|
|
||||||
client_key — TEXT NULL
|
|
||||||
signature — TEXT NULL
|
|
||||||
|
|
||||||
Ограничение:
|
|
||||||
UNIQUE(login, param)
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
обновление принимается только если excluded.time_ms > users_params.time_ms
|
|
||||||
|
|
||||||
ip_geo_cache
|
|
||||||
ip — TEXT PK
|
|
||||||
geo — TEXT NULL
|
|
||||||
updated_at_ms — INTEGER NOT NULL
|
|
||||||
|
|
||||||
blockchain_state
|
|
||||||
blockchain_name — TEXT PK
|
|
||||||
login — TEXT NOT NULL, FK → solana_users(login)
|
|
||||||
blockchain_key — TEXT NOT NULL
|
|
||||||
size_limit — INTEGER NOT NULL
|
|
||||||
file_size_bytes — INTEGER NOT NULL
|
|
||||||
last_global_number — INTEGER NOT NULL (-1 = genesis)
|
|
||||||
last_global_hash — TEXT NOT NULL
|
|
||||||
updated_at_ms — INTEGER NOT NULL
|
|
||||||
|
|
||||||
Линии 0..7:
|
|
||||||
для каждой линии:
|
|
||||||
lineX_last_number
|
|
||||||
lineX_last_hash
|
|
||||||
|
|
||||||
blocks
|
|
||||||
login — TEXT NOT NULL
|
|
||||||
bch_name — TEXT NOT NULL
|
|
||||||
block_global_number — INTEGER NOT NULL
|
|
||||||
block_global_pre_hash — TEXT NOT NULL
|
|
||||||
block_line_index — INTEGER NOT NULL
|
|
||||||
block_line_number — INTEGER NOT NULL
|
|
||||||
block_line_pre_hash — TEXT NOT NULL
|
|
||||||
msg_type — INTEGER NOT NULL
|
|
||||||
msg_sub_type — INTEGER NOT NULL
|
|
||||||
block_bytes — BLOB NULL
|
|
||||||
|
|
||||||
Ссылка на другой блок (nullable):
|
|
||||||
to_login
|
|
||||||
to_bch_name
|
|
||||||
to_block_global_number
|
|
||||||
to_block_hash
|
|
||||||
|
|
||||||
connections_state ⭐
|
|
||||||
Текущее агрегированное состояние связей.
|
|
||||||
|
|
||||||
login — TEXT NOT NULL
|
|
||||||
rel_type — INTEGER NOT NULL
|
|
||||||
10 = FRIEND
|
|
||||||
20 = CONTACT
|
|
||||||
30 = FOLLOW
|
|
||||||
to_login — TEXT NOT NULL
|
|
||||||
to_bch_name — TEXT NOT NULL
|
|
||||||
to_block_global_number — INTEGER NULL
|
|
||||||
to_block_hash — TEXT NULL
|
|
||||||
|
|
||||||
Ограничение:
|
|
||||||
UNIQUE(login, rel_type, to_login)
|
|
||||||
|
|
||||||
message_stats ⭐
|
|
||||||
Счётчики активности по целевому сообщению.
|
|
||||||
|
|
||||||
to_login — TEXT NOT NULL
|
|
||||||
to_bch_name — TEXT NOT NULL
|
|
||||||
to_block_global_number — INTEGER NOT NULL
|
|
||||||
to_block_hash — TEXT NOT NULL
|
|
||||||
likes_count — INTEGER NOT NULL DEFAULT 0
|
|
||||||
replies_count — INTEGER NOT NULL DEFAULT 0
|
|
||||||
|
|
||||||
UNIQUE:
|
|
||||||
(to_login, to_bch_name, to_block_global_number, to_block_hash)
|
|
||||||
|
|
||||||
Триггеры БД (полная логика)
|
|
||||||
|
|
||||||
3.1 Связи пользователей
|
|
||||||
trg_blocks_connection_state_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
|
|
||||||
Условие:
|
|
||||||
msg_type = 3 (connection)
|
|
||||||
|
|
||||||
Добавление / обновление связи
|
|
||||||
msg_sub_type IN (10,20,30)
|
|
||||||
выполняется UPSERT в connections_state
|
|
||||||
|
|
||||||
Удаление связи
|
|
||||||
msg_sub_type IN (11,21,31)
|
|
||||||
удаляется соответствующая связь:
|
|
||||||
11 → 10
|
|
||||||
21 → 20
|
|
||||||
31 → 30
|
|
||||||
|
|
||||||
Итог:
|
|
||||||
blocks — журнал событий
|
|
||||||
connections_state — всегда актуальное состояние
|
|
||||||
|
|
||||||
3.2 Подсчёт лайков ⭐
|
|
||||||
trg_blocks_message_stats_like_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
|
|
||||||
Условие:
|
|
||||||
msg_type = 2 (reaction)
|
|
||||||
msg_sub_type = 1 (like)
|
|
||||||
|
|
||||||
Действие:
|
|
||||||
определяется цель по to_bch_name, to_block_global_number, to_block_hash
|
|
||||||
to_login вычисляется как
|
|
||||||
substr(to_bch_name, 1, length(to_bch_name) - 3)
|
|
||||||
выполняется UPSERT в message_stats
|
|
||||||
likes_count += 1
|
|
||||||
|
|
||||||
3.3 Подсчёт ответов ⭐
|
|
||||||
trg_blocks_message_stats_reply_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
|
|
||||||
Условие:
|
|
||||||
msg_type = 1 (text)
|
|
||||||
msg_sub_type = 2 (reply)
|
|
||||||
|
|
||||||
Действие:
|
|
||||||
цель определяется аналогично лайкам
|
|
||||||
выполняется UPSERT в message_stats
|
|
||||||
replies_count += 1
|
|
||||||
|
|
||||||
Индексы (смысл)
|
|
||||||
|
|
||||||
idx_solana_users_login — поиск пользователя
|
|
||||||
idx_active_sessions_login — сессии пользователя
|
|
||||||
idx_users_params_login — параметры пользователя
|
|
||||||
idx_ip_geo_cache_updated_at — чистка кеша
|
|
||||||
idx_blockchain_state_login — блокчейны пользователя
|
|
||||||
idx_blockchain_state_updated_at — обслуживание
|
|
||||||
idx_blocks_chain_global — чтение цепочки
|
|
||||||
idx_blocks_to_target — реакции / ответы
|
|
||||||
idx_message_stats_target — быстрый доступ к счётчикам
|
|
||||||
|
|
||||||
Итоговая модель мышления
|
|
||||||
|
|
||||||
blocks — неизменяемый журнал событий
|
|
||||||
connections_state — проекция связей
|
|
||||||
message_stats — проекция активности
|
|
||||||
всё вычисляется детерминированно через триггеры
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
# Протокол звонков (MVP)
|
|
||||||
|
|
||||||
Версия: browser-to-browser, runtime-only signaling.
|
|
||||||
|
|
||||||
## Цели
|
|
||||||
- Технические сообщения звонка не сохраняются в БД direct_messages.
|
|
||||||
- Первый INVITE рассылается всем активным сессиям получателя и дублируется web push.
|
|
||||||
- Последующие сигналы идут только в конкретную sessionId и не дублируются в push.
|
|
||||||
|
|
||||||
## Операции API
|
|
||||||
|
|
||||||
### 1) CallInviteBroadcast
|
|
||||||
Отправляет общий вызов пользователю.
|
|
||||||
|
|
||||||
Запрос payload:
|
|
||||||
- `toLogin: string`
|
|
||||||
- `callId: string`
|
|
||||||
- `type: 100` (INVITE)
|
|
||||||
|
|
||||||
Поведение сервера:
|
|
||||||
- Рассылает `IncomingCallInvite` во все активные WS-сессии `toLogin`.
|
|
||||||
- В payload события передаёт:
|
|
||||||
- `fromLogin`
|
|
||||||
- `fromSessionId` (session инициатора)
|
|
||||||
- `toLogin`
|
|
||||||
- `callId`
|
|
||||||
- `type=100`
|
|
||||||
- `timeMs`
|
|
||||||
- Отправляет web push уведомление о входящем вызове.
|
|
||||||
|
|
||||||
Ответ payload:
|
|
||||||
- `callId`
|
|
||||||
- `deliveredWsSessions`
|
|
||||||
- `deliveredFcmSessions`
|
|
||||||
|
|
||||||
### 2) CallSignalToSession
|
|
||||||
Отправляет технический сигнал в конкретную сессию.
|
|
||||||
|
|
||||||
Запрос payload:
|
|
||||||
- `toLogin: string`
|
|
||||||
- `targetSessionId: string`
|
|
||||||
- `callId: string`
|
|
||||||
- `type: int`
|
|
||||||
- `data: string` (для SDP/ICE/служебных строк)
|
|
||||||
|
|
||||||
Поведение сервера:
|
|
||||||
- Ищет только `targetSessionId`.
|
|
||||||
- Проверяет, что сессия принадлежит `toLogin`.
|
|
||||||
- Отправляет `IncomingCallSignal` только в эту сессию.
|
|
||||||
- В БД ничего не сохраняет.
|
|
||||||
- Push не отправляет.
|
|
||||||
|
|
||||||
Ответ payload:
|
|
||||||
- `delivered: boolean`
|
|
||||||
|
|
||||||
## Коды type
|
|
||||||
- `100` INVITE
|
|
||||||
- `110` RINGING
|
|
||||||
- `120` ACCEPT
|
|
||||||
- `130` DECLINE_BUSY
|
|
||||||
- `140` TIMEOUT
|
|
||||||
- `150` HANGUP
|
|
||||||
- `200` OFFER
|
|
||||||
- `210` ANSWER
|
|
||||||
- `220` ICE
|
|
||||||
|
|
||||||
## Правила UI/логики
|
|
||||||
- Если уже есть активный звонок и пришел новый INVITE -> автоответ `DECLINE_BUSY` без UI.
|
|
||||||
- После ACCEPT `callId` остаётся во всех OFFER/ANSWER/ICE сообщениях до конца звонка.
|
|
||||||
- При параллельных звонках A<->B допускается детерминированное правило, кто создаёт OFFER.
|
|
||||||
|
|
||||||
## Тайминги MVP
|
|
||||||
- Ожидание подтверждения/реакции после INVITE: до 5с (у инициатора).
|
|
||||||
- Ожидание принятия у входящего звонка: 20с.
|
|
||||||
- Общий лимит ожидания до соединения: 22с.
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
Дальше делать:
|
|
||||||
Описание форматов.
|
|
||||||
Запросы клиент-сервер.
|
|
||||||
Промт на клиента.
|
|
||||||
|
|
||||||
---
|
|
||||||
Потом в сервак дописать синхронизацию серверов.
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
# SHiNE Deployment Servers Inventory
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
This folder contains all deployment-related notes and server records for SHiNE.
|
|
||||||
|
|
||||||
## Legacy Production Server
|
|
||||||
- Name: `VPS-02` (legacy)
|
|
||||||
- Access: `root@194.87.0.247`
|
|
||||||
- Current role: old production server
|
|
||||||
- Confirmed services:
|
|
||||||
- `coturn` is installed and active (`systemd: active/running`)
|
|
||||||
- `caddy` is installed (reported by project context; verify version on host if needed)
|
|
||||||
- TURN configuration observed on host:
|
|
||||||
- `listening-port=3478`
|
|
||||||
- `external-ip=194.87.0.247`
|
|
||||||
- `relay-ip=194.87.0.247`
|
|
||||||
- auth mode: `use-auth-secret` + `static-auth-secret`
|
|
||||||
- SHiNE deployment note:
|
|
||||||
- This host is used as current/legacy runtime for SHiNE.
|
|
||||||
- Gradle-based deployment is used in this project (see repository deploy tasks and scripts).
|
|
||||||
|
|
||||||
## Target Production Server (Migration)
|
|
||||||
- Name: `VPS-05` (new)
|
|
||||||
- Access: `root@45.136.124.227`
|
|
||||||
- Planned role: new primary production server for gradual migration
|
|
||||||
- Baseline setup done:
|
|
||||||
- `ripgrep` installed
|
|
||||||
- user `player` created
|
|
||||||
- user `player` added to `sudo` group
|
|
||||||
- deployment directory created: `/home/player/SHiNE`
|
|
||||||
- Rule:
|
|
||||||
- All SHiNE-related runtime files and deployments on VPS-05 should be placed under `/home/player/SHiNE`.
|
|
||||||
|
|
||||||
## Additional TURN Node
|
|
||||||
- Name: `promo-node-93`
|
|
||||||
- Access: `ubuntu@93.170.12.154` (and `player` user for SHiNE operations)
|
|
||||||
- Role: additional TURN node for SHiNE calls
|
|
||||||
- TURN setup:
|
|
||||||
- `coturn` installed and active
|
|
||||||
- `listening-port=3478`
|
|
||||||
- `tls-listening-port=5349`
|
|
||||||
- `use-auth-secret` + shared `static-auth-secret`
|
|
||||||
- relay UDP port range: `49152-50152`
|
|
||||||
- Runtime files:
|
|
||||||
- `/etc/turnserver.conf`
|
|
||||||
- `/home/player/SHiNE/coturn/turnserver.conf`
|
|
||||||
- Cleanup done:
|
|
||||||
- Disabled old reverse SSH tunnel (`reverse-ssh.service`) that exposed `0.0.0.0:1200 -> localhost:22` to `194.87.0.247`.
|
|
||||||
|
|
||||||
## Next Migration Steps (recommended)
|
|
||||||
1. Install and configure runtime dependencies (JDK, Caddy, DB, TURN if required).
|
|
||||||
2. Mirror SHiNE deployment process from VPS-02 using existing Gradle deployment flow.
|
|
||||||
3. Move traffic gradually and validate logs/metrics before final cutover.
|
|
||||||
@ -1,6 +1,11 @@
|
|||||||
# API для разработчиков: 04 — Добавление блока в блокчейн (AddBlock)
|
# API для разработчиков: 04 — Запись и чтение блока блокчейна
|
||||||
|
|
||||||
Документ описывает **текущий рабочий формат** сетевого вызова `AddBlock`, который используется для записи **любого** блока в блокчейн пользователя.
|
Документ описывает **текущий рабочий формат** сетевых вызовов:
|
||||||
|
|
||||||
|
- `AddBlock` — запись любого блока в блокчейн пользователя;
|
||||||
|
- `GetBlockchainBlock` — публичное чтение одного конкретного блока по имени цепочки и номеру.
|
||||||
|
|
||||||
|
`GetBlockchainBlock` нужен в том числе для межсерверной синхронизации и для открытого чтения публичного блокчейна по одному блоку.
|
||||||
|
|
||||||
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
|
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
|
||||||
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
|
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
|
||||||
@ -81,6 +86,7 @@
|
|||||||
- `bad_signature`, `signature_verify_failed`
|
- `bad_signature`, `signature_verify_failed`
|
||||||
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
||||||
- `limit_exceeded`
|
- `limit_exceeded`
|
||||||
|
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||||
- `internal_error`
|
- `internal_error`
|
||||||
|
|
||||||
@ -174,3 +180,60 @@
|
|||||||
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
|
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
|
||||||
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
|
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
|
||||||
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
|
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `GetBlockchainBlock`
|
||||||
|
|
||||||
|
### Назначение
|
||||||
|
|
||||||
|
Публичное чтение одного конкретного блока из цепочки.
|
||||||
|
|
||||||
|
Нужно для:
|
||||||
|
|
||||||
|
- открытого чтения блокчейна по одному блоку;
|
||||||
|
- межсерверной синхронизации;
|
||||||
|
- восстановления/докачки отсутствующего хвоста цепочки.
|
||||||
|
|
||||||
|
### JSON формат запроса
|
||||||
|
|
||||||
|
`op = "GetBlockchainBlock"`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetBlockchainBlock",
|
||||||
|
"requestId": "req-2001",
|
||||||
|
"payload": {
|
||||||
|
"blockchainName": "alice-001",
|
||||||
|
"blockNumber": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля `payload`:
|
||||||
|
|
||||||
|
- `blockchainName` — обязательно, формат `login-NNN`.
|
||||||
|
- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`.
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetBlockchainBlock",
|
||||||
|
"requestId": "req-2001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"blockchainName": "alice-001",
|
||||||
|
"blockNumber": 12,
|
||||||
|
"blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677",
|
||||||
|
"blockBytesB64": "AAAB..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`.
|
||||||
|
- `404 / BLOCK_NOT_FOUND` — такого блока нет.
|
||||||
|
- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера.
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
|
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
|
||||||
|
|
||||||
Сейчас здесь шесть методов:
|
Сейчас здесь восемь методов:
|
||||||
|
|
||||||
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
|
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
|
||||||
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
|
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
|
||||||
|
- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации;
|
||||||
|
- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC;
|
||||||
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
|
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
|
||||||
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
|
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
|
||||||
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
|
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
|
||||||
@ -15,6 +17,8 @@
|
|||||||
|
|
||||||
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
|
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
|
||||||
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
|
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
|
||||||
|
- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону.
|
||||||
|
- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC.
|
||||||
|
|
||||||
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
|
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
|
||||||
|
|
||||||
@ -134,7 +138,151 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. `GetCallIceConfig`
|
## 3. `ListBlockchainHeads`
|
||||||
|
|
||||||
|
### Назначение
|
||||||
|
|
||||||
|
Запрос краткой сводки по всем локальным блокчейнам сервера.
|
||||||
|
|
||||||
|
Нужен для межсерверной синхронизации. Партнёр может:
|
||||||
|
|
||||||
|
- получить список всех блокчейнов;
|
||||||
|
- сравнить `lastBlockNumber` и `lastBlockHash` со своими значениями;
|
||||||
|
- понять, какие цепочки нужно догонять;
|
||||||
|
- затем отдельно запросить недостающие блоки по диапазону.
|
||||||
|
|
||||||
|
Этот запрос доступен без авторизации.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ListBlockchainHeads",
|
||||||
|
"requestId": "heads-001",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ListBlockchainHeads",
|
||||||
|
"requestId": "heads-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"blockchains": [
|
||||||
|
{
|
||||||
|
"blockchainName": "alice_main",
|
||||||
|
"lastBlockNumber": 124,
|
||||||
|
"lastBlockHash": "aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb",
|
||||||
|
"fileSizeBytes": 58720
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поля ответа
|
||||||
|
|
||||||
|
- `blockchains` — массив текущих heads всех цепочек сервера.
|
||||||
|
- `blockchainName` — имя блокчейна.
|
||||||
|
- `lastBlockNumber` — последний номер блока в этой цепочке.
|
||||||
|
- `lastBlockHash` — последний хэш блока в HEX-формате `64` символа.
|
||||||
|
- `fileSizeBytes` — текущий размер файла блокчейна в байтах.
|
||||||
|
|
||||||
|
### Специфические коды ошибок `ListBlockchainHeads`
|
||||||
|
|
||||||
|
- У `ListBlockchainHeads` нет специальных прикладных ошибок при штатной работе.
|
||||||
|
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
Доступно только после успешной авторизации.
|
Доступно только после успешной авторизации.
|
||||||
|
|
||||||
@ -184,7 +332,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. `ClientErrorLog`
|
## 6. `ClientErrorLog`
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -231,7 +379,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. `ClientDebugLog`
|
## 7. `ClientDebugLog`
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -269,7 +417,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. `CallDeliveryReport`
|
## 8. `CallDeliveryReport`
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
|
|||||||
@ -32,8 +32,11 @@
|
|||||||
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
|
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
|
||||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||||
|
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
|
||||||
| `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 всех локальных блокчейнов |
|
||||||
|
| `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-лог |
|
||||||
|
|||||||
@ -1,5 +1,21 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
# 2026-06-26 17:45:18 +0400
|
||||||
|
- Базовый коммит-ориентир: `44a1ba0`.
|
||||||
|
- На `t.shineup.me` подтверждена рабочая схема startup sync и full-resync:
|
||||||
|
- после рестарта сервер добивает `BlockchainTmpRecovery` и `BlockchainResyncRecovery`;
|
||||||
|
- `aidartest-001` успешно подтягивается с `shineup.me`;
|
||||||
|
- итоговое локальное состояние по `aidartest-001` дошло до `last_block_number=13`.
|
||||||
|
- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлен практический результат ручной проверки на тестовом сервере.
|
||||||
|
|
||||||
|
## 2026-06-26 17:03:22 +0400
|
||||||
|
- Базовый коммит-ориентир: `71fdee0`.
|
||||||
|
- Обычный `AddBlock` переведён на crash-safe схему через временный кандидат `<blockchainName>.tmp_bch`, sidecar `<blockchainName>.write_check` и marker `<blockchainName>.write_pending`.
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` теперь разбирает marker-driven recovery для обычной записи блока:
|
||||||
|
- если marker есть, recovery либо завершает swap tmp -> main, либо удаляет мусор;
|
||||||
|
- если marker нет, временные артефакты считаются мусором и удаляются.
|
||||||
|
- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлено описание обычного `AddBlock` recovery и разделение между `write_pending` и `resync_pending`.
|
||||||
|
|
||||||
## 2026-05-24 11:40:00 +0300
|
## 2026-05-24 11:40:00 +0300
|
||||||
- Базовый коммит-ориентир: `abdce05`.
|
- Базовый коммит-ориентир: `abdce05`.
|
||||||
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
|
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
|
||||||
|
|||||||
@ -30,4 +30,5 @@
|
|||||||
|
|
||||||
## Обязательное сопровождение
|
## Обязательное сопровождение
|
||||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
||||||
|
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
|
||||||
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||||
|
|||||||
@ -39,33 +39,164 @@
|
|||||||
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
||||||
- Дедупликация по глобальному номеру блока и хэшу.
|
- Дедупликация по глобальному номеру блока и хэшу.
|
||||||
|
|
||||||
## 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`.
|
||||||
|
6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync:
|
||||||
|
- цепочка помечается in-memory как `resync in progress`;
|
||||||
|
- создаётся marker-file в `data/`;
|
||||||
|
- в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики;
|
||||||
|
- удаляются `.bch` и `.tmp_bch`;
|
||||||
|
- цепочка подтягивается заново с `0` через `GetBlockchainBlock`.
|
||||||
|
- обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`.
|
||||||
|
|
||||||
|
### 4.4 Как именно работает full resync
|
||||||
|
|
||||||
|
Full resync запускается только тогда, когда:
|
||||||
|
|
||||||
|
- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`;
|
||||||
|
- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу:
|
||||||
|
- `lastBlockNumber`;
|
||||||
|
- `fileSizeBytes`;
|
||||||
|
- `lastBlockHash`.
|
||||||
|
|
||||||
|
Порядок действий:
|
||||||
|
|
||||||
|
1. Ставится in-memory guard на `blockchainName`.
|
||||||
|
2. Создаётся marker-file `<blockchainName>.resync_pending`.
|
||||||
|
3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`.
|
||||||
|
4. Вызывается атомарный SQL cleanup одной chain:
|
||||||
|
- уменьшаются чужие `likes_count` и `replies_count`;
|
||||||
|
- удаляются локальные derived-state записи этой chain;
|
||||||
|
- удаляются `blocks` и `blockchain_state` этой chain.
|
||||||
|
5. Удаляются файлы `<blockchainName>.bch` и `<blockchainName>.tmp_bch`.
|
||||||
|
6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`.
|
||||||
|
7. Chain replay-ится с `0` через `GetBlockchainBlock`.
|
||||||
|
8. Если всё прошло успешно, marker-file удаляется.
|
||||||
|
9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте.
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- full resync не делает умный rollback по одному блоку;
|
||||||
|
- full resync не трогает DM-таблицы и `solana_users`;
|
||||||
|
- висячие cross-chain ссылки считаются допустимым поведением системы.
|
||||||
|
|
||||||
|
### 4.5 Как работает обычный `AddBlock` и его recovery
|
||||||
|
|
||||||
|
Обычная запись блока теперь тоже идёт через временные артефакты:
|
||||||
|
|
||||||
|
1. собирается `<blockchainName>.tmp_bch` как полный кандидат на замену основного файла;
|
||||||
|
2. пишется маленький sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
|
||||||
|
3. только после этого создаётся пустой marker `<blockchainName>.write_pending`;
|
||||||
|
4. выполняется SQL-транзакция;
|
||||||
|
5. после `commit` tmp атомарно ставится на место основного `.bch`;
|
||||||
|
6. marker и sidecar удаляются.
|
||||||
|
|
||||||
|
На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару:
|
||||||
|
|
||||||
|
- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы;
|
||||||
|
- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется;
|
||||||
|
- `resync_pending` сюда не относится, это отдельный recovery-поток.
|
||||||
|
|
||||||
|
### 4.6 Startup recovery по marker-file
|
||||||
|
|
||||||
|
При старте сервер идёт в таком порядке:
|
||||||
|
|
||||||
|
1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`;
|
||||||
|
2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`;
|
||||||
|
3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`.
|
||||||
|
|
||||||
|
Если marker-file существует:
|
||||||
|
|
||||||
|
- сервер не должен начинать обычную работу поверх этой chain;
|
||||||
|
- recovery снова выполняет cleanup и replay с нуля;
|
||||||
|
- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.
|
||||||
|
|
||||||
|
### 4.7 Зачем понадобился `GetSyncUserProfile`
|
||||||
|
|
||||||
|
Изначально подготовка локальной цепочки делалась через Solana:
|
||||||
|
|
||||||
|
- из `blockchainName` извлекался `login`;
|
||||||
|
- сервер вызывал import пользователя из Solana PDA;
|
||||||
|
- по данным PDA локально создавались `solana_users + blockchain_state`.
|
||||||
|
|
||||||
|
На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`.
|
||||||
|
|
||||||
|
Поэтому добавлен отдельный обходной режим:
|
||||||
|
|
||||||
|
- настройка `sync.importUserProfileFromPartner.enabled=true`
|
||||||
|
- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync;
|
||||||
|
- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра.
|
||||||
|
- если локальный `solana_users` уже существует, sync восстанавливает только `blockchain_state` и не трогает identity-слой.
|
||||||
|
|
||||||
|
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
|
||||||
|
|
||||||
|
### 4.8 Что делает настройка `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 +209,48 @@
|
|||||||
|
|
||||||
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
|
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
|
||||||
|
|
||||||
## 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` | ✅ Реализовано |
|
||||||
|
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||||
|
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
|
||||||
| Push новых DM партнёрам | Нужна реализация |
|
| Push новых DM партнёрам | Нужна реализация |
|
||||||
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
||||||
| Backfill при первом подключении | Нужна реализация |
|
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
||||||
|
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
||||||
|
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
|
||||||
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
||||||
|
|
||||||
Текущая версия сервера работает без межсерверной синхронизации.
|
Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами.
|
||||||
Синхронизация — задача следующего этапа разработки.
|
Не реализованы ещё DM-sync и постоянные server-to-server соединения.
|
||||||
|
|
||||||
|
Следующие отдельные шаги после текущего этапа:
|
||||||
|
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
||||||
|
|
||||||
|
### 8.1 Практическая проверка на тестовом сервере
|
||||||
|
|
||||||
|
Проверка на `t.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте:
|
||||||
|
|
||||||
|
- после рестарта сервер сначала проходит `BlockchainTmpRecovery`;
|
||||||
|
- затем обрабатывает `BlockchainResyncRecovery`;
|
||||||
|
- после этого сам догружает цепочку `aidartest-001` с `shineup.me`;
|
||||||
|
- итоговое состояние на тестовом сервере:
|
||||||
|
- `blockchain_state.last_block_number = 13`
|
||||||
|
- `blocks` по `aidartest-001` = `14` записей
|
||||||
|
|
||||||
|
Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде.
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
# Дальнее будущее
|
|
||||||
|
|
||||||
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
|
|
||||||
|
|
||||||
## Идеи
|
|
||||||
|
|
||||||
- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# Быстрое скрытие экрана звонка и остановка гудков при отклонении
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- Исправлена нижняя подпись вкладки личных сообщений на `личные`.
|
|
||||||
- Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии.
|
|
||||||
- На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа.
|
|
||||||
|
|
||||||
- что именно проверять:
|
|
||||||
- В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`.
|
|
||||||
- Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`.
|
|
||||||
- Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается.
|
|
||||||
- Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки.
|
|
||||||
- Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- Подпись в нижней панели корректная.
|
|
||||||
- При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков.
|
|
||||||
- Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены.
|
|
||||||
- Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# Отключение устаревшего TURN-узла `45.136.124.227`
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- из конфигурации звонков убран устаревший TURN-узел `45.136.124.227:3478`;
|
|
||||||
- основным и единственным выдаваемым TURN-узлом оставлен `93.170.12.154:3478`.
|
|
||||||
- что проверять:
|
|
||||||
- сделать несколько тестовых звонков между разными устройствами/сетями;
|
|
||||||
- убедиться, что звонок доходит до стадии соединения и появляется звук;
|
|
||||||
- убедиться, что в логах `CallDeliveryReport` больше не фигурирует `45.136.124.227`.
|
|
||||||
- ожидаемый результат:
|
|
||||||
- клиентам больше не выдаётся устаревший TURN-адрес;
|
|
||||||
- звонки не заваливаются из-за попыток использовать отключённый TURN-узел.
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# Фикс самообрыва звонка из-за `stop_call` push своей же сессии
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- исправлена ситуация, когда активный звонок мог оборваться сразу после соединения;
|
|
||||||
- причина была в том, что `stop_call` push, предназначенный для других сессий того же пользователя, обрабатывался и в исходной сессии.
|
|
||||||
- что проверять:
|
|
||||||
- открыть несколько вкладок/устройств одного пользователя;
|
|
||||||
- принять звонок на одной сессии;
|
|
||||||
- убедиться, что активная сессия не обрывает звонок сразу после соединения;
|
|
||||||
- убедиться, что лишние сессии при этом закрывают свой локальный экран звонка.
|
|
||||||
- ожидаемый результат:
|
|
||||||
- звонок не завершается сразу после `call_connected`;
|
|
||||||
- `accepted_on_other_device` и связанные `stop_call` события больше не убивают исходную активную сессию.
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
# Фикс привязки call push к целевой sessionId
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- push-события `incoming_call` и `stop_call` теперь помечаются целевой `sessionId`;
|
|
||||||
- UI и service worker обрабатывают call push только для своей целевой сессии;
|
|
||||||
- `stop_call` для лишних сессий закрывает локальный экран тихо, без обратных сигналов и без лишних тех-сообщений.
|
|
||||||
- что проверять:
|
|
||||||
- держать несколько сессий одного пользователя в одном браузере/на одном origin;
|
|
||||||
- позвонить этому пользователю и убедиться, что входящий экран закрывается корректно только на целевых сессиях;
|
|
||||||
- после `ACCEPT` одной сессии остальные должны тихо убрать экран вызова и не ломать выбранную пару;
|
|
||||||
- после отмены входящей сессией исходящая сессия должна централизованно завершить сценарий.
|
|
||||||
- ожидаемый результат:
|
|
||||||
- push одного session endpoint больше не влияет на чужие сессии этого же origin;
|
|
||||||
- исчезают ложные `stop_call_push:accepted_on_other_device` и `terminal_call_signal_150` на неправильных сессиях.
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# ESP32 homeserver: заявки на подключение устройств
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- на ESP32 homeserver в `SETTINGS` добавлен первый пункт `Device requests`, который появляется только после авторизации homeserver в SHiNE;
|
|
||||||
- экран показывает список активных pairing-заявок, позволяет открыть каждую заявку и подтвердить или отклонить её;
|
|
||||||
- формат кода подключения изменён на `10` цифр и показывается как `5` пар.
|
|
||||||
|
|
||||||
- что проверять:
|
|
||||||
- на обычном клиенте и в wallet-plugin код отображается как `XX XX XX XX XX`;
|
|
||||||
- на доверенном веб-клиенте экран `Подключить по коду` показывает все активные заявки без поля ручного ввода;
|
|
||||||
- на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым;
|
|
||||||
- `REFRESH` реально загружает активные заявки;
|
|
||||||
- на экране видно две плитки, список листается вертикально;
|
|
||||||
- client-session заявка после `YES` подключается с передачей только `client key`;
|
|
||||||
- wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session;
|
|
||||||
- `NO` отклоняет заявку и она исчезает из списка активных.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- все три клиента используют единый формат кода;
|
|
||||||
- активные заявки видны без ручного ввода кода;
|
|
||||||
- ESP32 может одобрять и отклонять живые pairing-заявки пользователя.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# Тестовые deploy-контуры `test2.shineup.me` и `test.shineup.me`
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- default deploy-задачи `deployServer` и `deployUI` переведены на основной тестовый сервер `test2.shineup.me`;
|
|
||||||
- production-задачи вынесены в `deployServerProduction` и `deployUIProduction`;
|
|
||||||
- `test.shineup.me` оставлен как резервный тестовый сервер без обычного deploy по умолчанию.
|
|
||||||
|
|
||||||
- что проверять:
|
|
||||||
- `./gradlew deployServer` и `./gradlew deployUI` действительно направлены на `test2.shineup.me`;
|
|
||||||
- `./gradlew deployServerProduction` и `./gradlew deployUIProduction` больше не используются как default;
|
|
||||||
- `https://test2.shineup.me` открывает UI;
|
|
||||||
- `wss://test2.shineup.me/ws` отвечает;
|
|
||||||
- на `test2.shineup.me` после deploy есть копия продовой `shine.sqlite` и `.bch`.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- default deploy идёт только на `test2.shineup.me`;
|
|
||||||
- production `shineup.me` меняется только после отдельного подтверждения;
|
|
||||||
- `test.shineup.me` остаётся резервным тестовым сервером;
|
|
||||||
- тестовый deploy не гоняет удалённые тесты и не создаёт пустую БД.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Исправление chatId личных сообщений через lowercase
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB.
|
|
||||||
- Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина.
|
|
||||||
|
|
||||||
- что именно проверять:
|
|
||||||
- Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами.
|
|
||||||
- Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе.
|
|
||||||
- Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге.
|
|
||||||
- Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина.
|
|
||||||
- Общий unread, список диалогов и содержимое открытого чата совпадают между собой.
|
|
||||||
- После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
# ESP32 выбор кошелька и wallet RPC для browser extension
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька;
|
|
||||||
- добавлен выбор активного кошелька `ClientKey / RootKey / Custom`;
|
|
||||||
- для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`;
|
|
||||||
- в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`.
|
|
||||||
|
|
||||||
- что проверять:
|
|
||||||
- на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`;
|
|
||||||
- экран `WALLET` открывается и показывает текущий тип кошелька;
|
|
||||||
- экран `WALLET_SELECT` переключает `ClientKey`, `RootKey` и `Custom`;
|
|
||||||
- для `Custom` открывается ввод имени и после сохранения derivation работает;
|
|
||||||
- `Показать баланс кошелька` читает баланс именно активного кошелька;
|
|
||||||
- `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька;
|
|
||||||
- browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32;
|
|
||||||
- extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер;
|
|
||||||
- для `client.key` и `root.key` проверка через PDA даёт ожидаемое совпадение.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`;
|
|
||||||
- browser extension получает ответ без ручного ввода `walletSelector`;
|
|
||||||
- homeserver выбирается из опубликованных в PDA sessions и запрос приходит в нужное устройство;
|
|
||||||
- копирование ключа из extension работает.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# ESP32 English UI and trusted login fix
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
переведён экранный UI ESP32 homeserver на английский язык; добавлена локальная инструкция для AGENTS по ограничению кириллицы; исправлено падение `StartTrustedDeviceLogin`, когда у пользователя ещё нет записи `esp_pairing_settings`.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском.
|
|
||||||
2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения.
|
|
||||||
3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login.
|
|
||||||
4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`.
|
|
||||||
5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- ESP32 UI читается на устройстве без кириллических строк.
|
|
||||||
- Подключение через trusted-device login стартует без server-side `INTERNAL_HANDLER_ERROR`, даже если настройки pairing ещё ни разу не сохранялись.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
# Browser wallet side panel
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
browser extension `SHiNE Wallet` переведён с toolbar popup на штатный Chromium side panel.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. Перезагрузить unpacked extension в Chromium-браузере.
|
|
||||||
2. Нажать на иконку `SHiNE Wallet` в toolbar.
|
|
||||||
3. Убедиться, что открывается side panel, а не всплывающий popup.
|
|
||||||
4. Проверить, что панель можно держать открытой при навигации по сайтам.
|
|
||||||
5. Проверить сценарии `Подключить`, `Отключить`, `Обновить устройства`, `Запросить кошелёк`.
|
|
||||||
6. Проверить, что сторона панели управляется браузером, а UI корректно выглядит и слева, и справа.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- расширение открывается в штатной боковой панели Chromium;
|
|
||||||
- UI работает так же, как раньше в popup;
|
|
||||||
- панель остаётся доступной как постоянная колонка браузера.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Wallet provider and ESP sign_transaction
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
browser extension `SHiNE Wallet` теперь внедряет `window.solana` для сайтов и умеет выполнять `connect` и `signTransaction`; подпись транзакции уходит на ESP32 через wallet RPC `sign_transaction`, а подтверждение делается на устройстве.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. Перезагрузить unpacked extension в Chromium.
|
|
||||||
2. Открыть сайт/тестовую страницу, которая вызывает `window.solana.connect()`.
|
|
||||||
3. Подтвердить подключение кошелька и убедиться, что сайт получает публичный ключ.
|
|
||||||
4. Открыть сайт/тестовую страницу, которая вызывает `window.solana.signTransaction(...)`.
|
|
||||||
5. Убедиться, что на ESP32 открывается экран `SIGN REQUEST` с комментарием.
|
|
||||||
6. Проверить оба варианта:
|
|
||||||
- `APPROVE` возвращает сайту подписанную транзакцию;
|
|
||||||
- `REJECT` возвращает отказ.
|
|
||||||
7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- сайт может подключить кошелёк через provider расширения;
|
|
||||||
- транзакция подписывается только после подтверждения на ESP32;
|
|
||||||
- отказ на ESP32 корректно доходит до сайта.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Wallet Standard support
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
расширение `SHiNE Wallet` теперь не только внедряет legacy `window.solana`, но и регистрирует себя как `Wallet Standard` wallet для Solana dapp.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. Перезагрузить unpacked extension.
|
|
||||||
2. Открыть dapp, который использует Wallet Standard, например `app.realms.today` на `devnet`.
|
|
||||||
3. Открыть список кошельков и убедиться, что `SHiNE Wallet` появился отдельным вариантом.
|
|
||||||
4. Проверить `connect`.
|
|
||||||
5. Проверить подпись транзакции через сценарий dapp, который использует стандартный wallet interface.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- dapp видит `SHiNE Wallet` как standard wallet, а не только как legacy Phantom-style provider.
|
|
||||||
- connect и подпись работают через тот же ESP32 approval-flow.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
## Кратко
|
|
||||||
|
|
||||||
Исправлена ESP32-ветка обновления `user_pda` для добавления `homeserver`-сессии после миграции формата PDA на `RecoveryKeyBlock`.
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
|
|
||||||
- В `shine_homeserver_main.ino` синхронизирован `create/update` payload с новым форматом `shine_users`.
|
|
||||||
- В сериализацию и парсинг PDA добавлен `RecoveryKeyBlock`.
|
|
||||||
- Для ветки `Add Homeserver` добавлены промежуточные checkpoint-записи в NVS, чтобы после crash или reset было видно, на каком шаге оборвалась операция.
|
|
||||||
- В `ESP32/AGENTS.md` добавлена памятка по чтению `last_error`.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- Зарегистрировать или использовать уже существующий аккаунт на ESP32.
|
|
||||||
- Дойти до состояния `homeserver not in PDA`.
|
|
||||||
- Нажать `Add Homeserver`.
|
|
||||||
- Если операция не успешна, считать `last_error` по USB serial и убедиться, что видна свежая запись именно по шагам `Homeserver PDA update ...`, а не старый diag.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- `Add Homeserver` добавляет `homeserver1` в `sessions` блока `SessionsBlock`.
|
|
||||||
- Если операция падает, в NVS сохраняется свежая диагностическая запись с текущим этапом, а не устаревший лог регистрации.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
`pending`
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# Стартовая загрузка `sync_servers` из server PDA
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- При запуске сервер читает свой логин из `server.SHiNE.login`, загружает свою server PDA из Solana, достаёт `sync_servers`, затем читает PDA партнёров и сохраняет их `login + server_address + updated_at_ms` в локальную таблицу `sync_servers`.
|
|
||||||
- Что проверять:
|
|
||||||
- В `application.properties` задан `server.SHiNE.login=shineupme`.
|
|
||||||
- После старта сервера в SQLite появилась/обновилась таблица `sync_servers`.
|
|
||||||
- В таблице лежат логины и адреса серверов из `sync_servers` текущего server PDA.
|
|
||||||
- При изменении `sync_servers` или `server_address` в Solana и перезапуске сервера локальная таблица обновляется.
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Сервер без ручного ввода адресов подтягивает партнёров синхронизации из Solana PDA и хранит их локально для следующих этапов репликации.
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# Фоновая one-shot синхронизация `AddBlock` на `sync_servers`
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- После успешного локального `AddBlock` сервер в фоне пытается отправить тот же блок всем партнёрам из локальной таблицы `sync_servers`.
|
|
||||||
- Если партнёр отвечает `bad_prev_hash` или `bad_block_number`, сервер один раз делает backfill: читает недостающие блоки из БД по диапазону и досылает их по одному.
|
|
||||||
- Если в процессе возникает новая ошибка, попытка для этого партнёра прерывается без повторов.
|
|
||||||
- Что проверять:
|
|
||||||
- При добавлении нового блока клиент получает быстрый `OK`, не ожидая завершения межсерверной рассылки.
|
|
||||||
- В логах видно попытки отправки на адреса из `sync_servers`.
|
|
||||||
- При отставании партнёра сервер досылает пропущенный хвост блоков по одному.
|
|
||||||
- При ошибке после backfill сервер не зацикливается и не блокирует основной `AddBlock`.
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Репликация `AddBlock` работает в фоне и не ломает основной путь записи блока.
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
## Краткое описание
|
|
||||||
|
|
||||||
Доработан UX личного чата на мобильных устройствах:
|
|
||||||
- при открытой экранной клавиатуре нижний тулбар на 5 кнопок временно скрывается;
|
|
||||||
- после отправки собственного сообщения чат автоматически прокручивается вниз так, чтобы новое сообщение было видно сразу.
|
|
||||||
- при открытии уже существующего личного чата стартовая позиция выбирается по хвосту переписки:
|
|
||||||
- если есть непрочитанные сообщения, открытие происходит на линии `Новые сообщения`;
|
|
||||||
- если непрочитанных нет, чат открывается сразу в самом низу.
|
|
||||||
- на мобильной экранной клавиатуре `Enter` больше не отправляет сообщение, а создаёт новую строку;
|
|
||||||
- отправка выполняется только кнопкой `Отправить`;
|
|
||||||
- после отправки фокус остаётся в поле ввода, чтобы экранная клавиатура не закрывалась автоматически.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- открыть личный чат на телефоне;
|
|
||||||
- тапнуть в поле ввода и убедиться, что нижний тулбар скрывается после появления клавиатуры;
|
|
||||||
- закрыть клавиатуру и убедиться, что тулбар возвращается;
|
|
||||||
- отправить короткое сообщение, находясь не в самом низу переписки;
|
|
||||||
- убедиться, что после отправки экран прокручивается вниз и новое сообщение видно сразу;
|
|
||||||
- проверить то же поведение после прихода подтверждения отправки/перерисовки списка.
|
|
||||||
- открыть существующий чат с непрочитанными сообщениями и убедиться, что видна линия `Новые сообщения` и сообщения ниже неё;
|
|
||||||
- открыть существующий чат без непрочитанных сообщений и убедиться, что открыт конец переписки, а не начало.
|
|
||||||
- на телефоне нажать кнопку `Enter` на экранной клавиатуре и убедиться, что появляется новая строка, а сообщение не уходит;
|
|
||||||
- нажать кнопку отправки и убедиться, что сообщение отправилось, осталось видимым внизу и клавиатура не закрылась;
|
|
||||||
- отдельно проверить два сценария прокрутки после отправки:
|
|
||||||
- пользователь уже почти внизу и прокрутка идёт плавно;
|
|
||||||
- пользователь был заметно выше и чат догоняет новый хвост без лишних скачков.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- клавиатура не конфликтует по высоте с нижним тулбаром;
|
|
||||||
- при наборе доступно больше вертикального места;
|
|
||||||
- собственное только что отправленное сообщение сразу попадает в видимую область.
|
|
||||||
- при открытии чата пользователь сразу попадает в актуальную часть переписки.
|
|
||||||
- мобильный ввод не конфликтует с привычным поведением экранной клавиатуры.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
`pending`
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
## Краткое описание
|
|
||||||
|
|
||||||
Доработаны входящие уведомления для личных сообщений в сценарии, когда UI открыт, но страница скрыта на телефоне:
|
|
||||||
|
|
||||||
- для входящего DM при `document.visibilityState !== visible` UI пытается показать системное уведомление через `service worker`;
|
|
||||||
- добавлен `best effort` сигнал через `navigator.vibrate()`;
|
|
||||||
- добавлен короткий локальный звуковой сигнал через Web Audio, если аудио-контекст был ранее разблокирован пользовательским действием.
|
|
||||||
- для видимой активной страницы этот же сигнал теперь проигрывается на каждое новое входящее DM;
|
|
||||||
- для скрытой страницы звуковой сигнал сделан длиннее и заметнее.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- открыть SHiNE в Chrome/Android и один раз взаимодействовать со страницей;
|
|
||||||
- свернуть браузер или увести вкладку в фон, не закрывая её полностью;
|
|
||||||
- отправить DM с другого аккаунта;
|
|
||||||
- при открытой видимой странице тоже отправить DM и убедиться, что короткий сигнал воспроизводится без системного уведомления в шторке;
|
|
||||||
- проверить, что:
|
|
||||||
- сообщение пришло в шторку как системное уведомление;
|
|
||||||
- при поддержке устройства есть вибрация;
|
|
||||||
- на части устройств/браузеров может прозвучать локальный сигнал;
|
|
||||||
- отдельно проверить, что при открытой видимой странице не появилось лишних дублей системного уведомления.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- скрытая, но живая страница стала заметнее реагировать на входящий DM;
|
|
||||||
- уведомление в фоне не зависит только от `new Notification(...)` из страницы;
|
|
||||||
- если браузер разрешает локальный аудио-сигнал, пользователь слышит короткое оповещение.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
`pending`
|
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Crash-safe запись обычного `AddBlock` через `tmp_bch`
|
||||||
|
|
||||||
|
## Кратко
|
||||||
|
|
||||||
|
Обычный `AddBlock` переведён на схему:
|
||||||
|
|
||||||
|
1. сборка `<blockchainName>.tmp_bch`;
|
||||||
|
2. запись sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
|
||||||
|
3. создание пустого marker `<blockchainName>.write_pending`;
|
||||||
|
4. SQL-транзакция;
|
||||||
|
5. атомарная подмена `tmp -> main`;
|
||||||
|
6. удаление временных файлов.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
|
||||||
|
1. Обычный `AddBlock` на свежей цепочке.
|
||||||
|
2. Падение до SQL-commit:
|
||||||
|
- должны остаться только временные файлы;
|
||||||
|
- на старте они должны быть удалены.
|
||||||
|
3. Падение после SQL-commit, но до `atomicReplaceBlockchainFile(...)`:
|
||||||
|
- на старте recovery должен довести swap до конца.
|
||||||
|
4. Падение после `atomicReplaceBlockchainFile(...)`, но до удаления marker/sidecar:
|
||||||
|
- на старте recovery должен просто подчистить хвост.
|
||||||
|
5. Сценарий без marker:
|
||||||
|
- `tmp_bch` / `write_check` считаются мусором и удаляются.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- БД и файловая версия цепочки остаются согласованными.
|
||||||
|
- Повторный старт сервера не ломает chain и не требует ручной правки файлов.
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` корректно обрабатывает и живые остатки, и мусор.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
`pending`
|
||||||
|
|
||||||
|
## Что уже сделано
|
||||||
|
|
||||||
|
- В коде есть `tmp_bch`, `write_check` и `write_pending`.
|
||||||
|
- `BlockchainWriter` пишет обычный `AddBlock` через временные артефакты.
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` умеет добивать или чистить незавершённую запись.
|
||||||
|
|
||||||
|
## Что ещё перепроверить
|
||||||
|
|
||||||
|
- ручной crash-test на тестовом сервере;
|
||||||
|
- совместимость с уже существующими `resync_pending` marker-файлами;
|
||||||
|
- отсутствие ложных срабатываний на старых временных файлах.
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Проверка аварийных остановок на разных этапах
|
||||||
|
|
||||||
|
## Кратко
|
||||||
|
|
||||||
|
Нужно отдельно проверить, как сервер восстанавливается после внезапной остановки:
|
||||||
|
|
||||||
|
1. во время обычного `AddBlock` / `tmp_bch`-pipeline;
|
||||||
|
2. во время `full resync` цепочки;
|
||||||
|
3. во время startup recovery, если остановка произошла на предыдущем запуске;
|
||||||
|
4. при обычном апгрейде сервиса без явного crash-сценария.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
1. Остановка сервиса до `commit` БД.
|
||||||
|
2. Остановка сервиса после `commit`, но до замены `main.bch`.
|
||||||
|
3. Остановка сервиса во время `BlockchainResyncCleanupDAO`.
|
||||||
|
4. Остановка сервиса во время повторной загрузки цепочки по `GetBlockchainBlock`.
|
||||||
|
5. Поведение при обычном `systemctl restart`, когда сервер сам должен добить recovery.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- после старта сервер либо дочищает временные артефакты, либо завершает незаконченный `resync`;
|
||||||
|
- не остаётся битых `.tmp_bch`, `.write_check`, `.write_pending`, `.resync_pending`;
|
||||||
|
- БД и файлы цепочки остаются согласованными;
|
||||||
|
- обычная работа сервера не стартует поверх незавершённого recovery.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
`pending`
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# TODO: доработка персональных сообщений для агентов
|
|
||||||
|
|
||||||
Статус: отложено.
|
|
||||||
|
|
||||||
## Что хотели сделать
|
|
||||||
|
|
||||||
Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы:
|
|
||||||
|
|
||||||
- отличать сообщения человеку от сообщений агенту;
|
|
||||||
- отличать сообщения от человека и от агента;
|
|
||||||
- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`);
|
|
||||||
- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина.
|
|
||||||
|
|
||||||
## Базовая идея формата (черновик)
|
|
||||||
|
|
||||||
Пример префикса:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"}
|
|
||||||
Текст сообщения...
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример ответа агента:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"}
|
|
||||||
Ответ агента...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Почему отложено
|
|
||||||
|
|
||||||
- нужно отдельно согласовать финальный формат инструкции;
|
|
||||||
- нужно определить строгие правила UI-фильтрации и fallback;
|
|
||||||
- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий.
|
|
||||||
|
|
||||||
## Что сделать при возвращении к задаче
|
|
||||||
|
|
||||||
1. Зафиксировать окончательный формат префикса и JSON-полей.
|
|
||||||
2. Описать правила парсинга/валидации (включая битые/неполные префиксы).
|
|
||||||
3. Добавить UI-логику показа/скрытия agent-сообщений.
|
|
||||||
4. Добавить маркировку «ответ агента» в диалоге.
|
|
||||||
5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина.
|
|
||||||
@ -21,7 +21,7 @@
|
|||||||
- IP: `185.229.109.118`
|
- IP: `185.229.109.118`
|
||||||
- Main test:
|
- Main test:
|
||||||
- SSH: `player@193.8.215.70`
|
- SSH: `player@193.8.215.70`
|
||||||
- Домен: `test2.shineup.me`
|
- Домен: `t.shineup.me`
|
||||||
- IP: `193.8.215.70`
|
- IP: `193.8.215.70`
|
||||||
- Reserve test:
|
- Reserve test:
|
||||||
- SSH: `player@93.170.12.154`
|
- SSH: `player@93.170.12.154`
|
||||||
@ -42,9 +42,9 @@
|
|||||||
|
|
||||||
- `shineup.me` — production.
|
- `shineup.me` — production.
|
||||||
- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя.
|
- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя.
|
||||||
- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `test2.shineup.me`.
|
- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `t.shineup.me`.
|
||||||
|
|
||||||
## Main test deploy (`test2.shineup.me`)
|
## Main test deploy (`t.shineup.me`)
|
||||||
|
|
||||||
- Это основной сервер для тестов.
|
- Это основной сервер для тестов.
|
||||||
- `deployServer` и `deployUI` по умолчанию направлены именно сюда.
|
- `deployServer` и `deployUI` по умолчанию направлены именно сюда.
|
||||||
@ -55,12 +55,13 @@
|
|||||||
- перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`;
|
- перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`;
|
||||||
- установку `systemd` unit на `193.8.215.70`;
|
- установку `systemd` unit на `193.8.215.70`;
|
||||||
- перезапуск `shine-server.service`;
|
- перезапуск `shine-server.service`;
|
||||||
- установку/проверку Caddy для `test2.shineup.me`.
|
- установку/проверку Caddy для `t.shineup.me`.
|
||||||
- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`.
|
- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`.
|
||||||
|
|
||||||
## Reserve test deploy (`test.shineup.me`)
|
## Reserve test deploy (`test.shineup.me`)
|
||||||
|
|
||||||
- `test.shineup.me` пока не использовать для обычного deploy.
|
- `test.shineup.me` считается резервным тестовым сервером.
|
||||||
|
- Его настройки и адрес не менять без отдельной задачи.
|
||||||
- Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины.
|
- Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины.
|
||||||
|
|
||||||
## UI-деплой и Caddy (обязательно)
|
## UI-деплой и Caddy (обязательно)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
# Сервер `193.8.215.70` — основной test (`test2.shineup.me`)
|
# Сервер `193.8.215.70` — основной test (`t.shineup.me`)
|
||||||
|
|
||||||
- Пользователь: `player`
|
- Пользователь: `player`
|
||||||
- Домен: `test2.shineup.me`
|
- Домен: `t.shineup.me`
|
||||||
|
- Логин сервера: `tshineupme`
|
||||||
- Каталог SHiNE: `/home/player/SHiNE`
|
- Каталог SHiNE: `/home/player/SHiNE`
|
||||||
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
|
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
|
||||||
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
@ -25,9 +26,9 @@
|
|||||||
|
|
||||||
- Конфиг: `/etc/caddy/Caddyfile`
|
- Конфиг: `/etc/caddy/Caddyfile`
|
||||||
- Сайты:
|
- Сайты:
|
||||||
- `test2.shineup.me`
|
- `t.shineup.me`
|
||||||
- `agent.shiningpeople.ru`
|
- `agent.shiningpeople.ru`
|
||||||
- Для `test2.shineup.me`:
|
- Для `t.shineup.me`:
|
||||||
- `root * /home/player/SHiNE/shine-ui`
|
- `root * /home/player/SHiNE/shine-ui`
|
||||||
- `try_files {path} /index.html`
|
- `try_files {path} /index.html`
|
||||||
- `reverse_proxy /ws* -> 127.0.0.1:7070`
|
- `reverse_proxy /ws* -> 127.0.0.1:7070`
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
# Сервер `93.170.12.154` — test.shineup.me
|
# Сервер `93.170.12.154` — резервный test (`test.shineup.me`)
|
||||||
|
|
||||||
- Пользователь: `player`
|
- Пользователь: `player`
|
||||||
- Каталог SHiNE: `/home/player/SHiNE`
|
- Каталог SHiNE: `/home/player/SHiNE`
|
||||||
- Домен: `test.shineup.me`
|
- Домен: `test.shineup.me`
|
||||||
|
- Роль: резервный тестовый сервер
|
||||||
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
|
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
|
||||||
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
- Данные: `/home/player/SHiNE/shine-server/data/`
|
- Данные: `/home/player/SHiNE/shine-server/data/`
|
||||||
|
|||||||
@ -59,7 +59,7 @@ shine-UI/server-ui.html
|
|||||||
./gradlew deployUI
|
./gradlew deployUI
|
||||||
```
|
```
|
||||||
|
|
||||||
Default deploy по умолчанию идёт на `test2.shineup.me` (`player@193.8.215.70`).
|
Default deploy по умолчанию идёт на `t.shineup.me` (`player@193.8.215.70`).
|
||||||
|
|
||||||
Production deploy:
|
Production deploy:
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ Production deploy:
|
|||||||
./gradlew deployUITest
|
./gradlew deployUITest
|
||||||
```
|
```
|
||||||
|
|
||||||
`test.shineup.me` пока не использовать для обычного deploy.
|
`test.shineup.me` считается резервным тестовым сервером и в обычный deploy не включается.
|
||||||
|
|
||||||
Логи на проде:
|
Логи на проде:
|
||||||
- `/home/player/SHiNE/shine-server/logs/app.log`
|
- `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import java.util.Objects;
|
|||||||
* Теперь поддерживает:
|
* Теперь поддерживает:
|
||||||
* - основной файл блокчейна: <blockchainName>.bch
|
* - основной файл блокчейна: <blockchainName>.bch
|
||||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
||||||
|
* - sidecar-файл проверки записи: <blockchainName>.write_check
|
||||||
|
* - marker-файл записи: <blockchainName>.write_pending
|
||||||
*
|
*
|
||||||
* Важное:
|
* Важное:
|
||||||
* - validateSimpleFileName() запрещает path traversal.
|
* - validateSimpleFileName() запрещает path traversal.
|
||||||
@ -29,6 +31,15 @@ public final class FileStoreUtil {
|
|||||||
/** Расширение временного файла (старое+новое). */
|
/** Расширение временного файла (старое+новое). */
|
||||||
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
||||||
|
|
||||||
|
/** Маркер того, что chain сейчас в процессе полного resync. */
|
||||||
|
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
||||||
|
|
||||||
|
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
|
||||||
|
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
|
||||||
|
|
||||||
|
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
|
||||||
|
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
|
||||||
|
|
||||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
||||||
|
|
||||||
private final Path dataDirPath;
|
private final Path dataDirPath;
|
||||||
@ -130,6 +141,87 @@ public final class FileStoreUtil {
|
|||||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.write_check */
|
||||||
|
public String buildBlockchainWriteCheckFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
|
||||||
|
StringBuilder sb = new StringBuilder(128);
|
||||||
|
sb.append("blockNumber=").append(blockNumber).append('\n');
|
||||||
|
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
|
||||||
|
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.write_pending */
|
||||||
|
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainWritePendingMarker(String blockchainName) {
|
||||||
|
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.resync_pending */
|
||||||
|
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
|
||||||
|
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteIfExists(Path path) {
|
||||||
|
if (path == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainFileIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsBlockchainResyncMarker(String blockchainName) {
|
||||||
|
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарно заменить основной файл блокчейна временным:
|
* Атомарно заменить основной файл блокчейна временным:
|
||||||
* <name>.tmp_bch -> <name>.bch
|
* <name>.tmp_bch -> <name>.bch
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package utils.config;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
|
||||||
public final class AppConfig {
|
public final class AppConfig {
|
||||||
@ -26,18 +29,30 @@ public final class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void load() {
|
private void load() {
|
||||||
try (InputStream in = getClass().getClassLoader()
|
try (InputStream in = getClass().getClassLoader().getResourceAsStream("application.properties")) {
|
||||||
.getResourceAsStream("application.properties")) {
|
|
||||||
|
|
||||||
if (in == null) {
|
if (in == null) {
|
||||||
throw new RuntimeException("Config file application.properties not found");
|
throw new RuntimeException("Config file application.properties not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
properties.load(in);
|
properties.load(in);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to load application.properties", e);
|
throw new RuntimeException("Failed to load application.properties", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Path externalConfig = Paths.get("application.properties");
|
||||||
|
if (!Files.isRegularFile(externalConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Properties override = new Properties();
|
||||||
|
try (InputStream in = Files.newInputStream(externalConfig)) {
|
||||||
|
override.load(in);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to load external application.properties from " + externalConfig.toAbsolutePath(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String name : override.stringPropertyNames()) {
|
||||||
|
properties.setProperty(name, override.getProperty(name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вернёт значение строки или null, если параметр не найден */
|
/** Вернёт значение строки или null, если параметр не найден */
|
||||||
|
|||||||
@ -0,0 +1,394 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DatabaseInitializer;
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockchainResyncCleanupDAO — подготовительный "жёсткий reset" одной blockchain-цепочки
|
||||||
|
* перед её полной повторной загрузкой от сервера-партнёра.
|
||||||
|
*
|
||||||
|
* Что делает этот DAO:
|
||||||
|
* 1) в ОДНОЙ SQL-транзакции сначала аккуратно уменьшает чужие агрегаты,
|
||||||
|
* которые были увеличены блоками удаляемой цепочки:
|
||||||
|
* - likes_count
|
||||||
|
* - replies_count
|
||||||
|
* 2) затем удаляет все локальные записи самой цепочки и её производные состояния.
|
||||||
|
*
|
||||||
|
* Почему это вынесено в отдельный DAO-метод, а не в триггеры DELETE:
|
||||||
|
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||||
|
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||||
|
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||||
|
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||||
|
* SQLite не может атомарно закоммитить и SQL, и файловую систему сразу;
|
||||||
|
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||||
|
* отдельным recovery/resync-слоем после успешного commit.
|
||||||
|
*
|
||||||
|
* Важный смысл текущей реализации:
|
||||||
|
* - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы;
|
||||||
|
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
||||||
|
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
||||||
|
*/
|
||||||
|
public final class BlockchainResyncCleanupDAO {
|
||||||
|
|
||||||
|
private static final int BLOCKCHAIN_LOGIN_SUFFIX_LEN = 4; // "-001"
|
||||||
|
|
||||||
|
private static volatile BlockchainResyncCleanupDAO instance;
|
||||||
|
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private BlockchainResyncCleanupDAO() {}
|
||||||
|
|
||||||
|
public static BlockchainResyncCleanupDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (BlockchainResyncCleanupDAO.class) {
|
||||||
|
if (instance == null) instance = new BlockchainResyncCleanupDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью очищает одну blockchain-цепочку и локальные derived-state, собранные из неё.
|
||||||
|
*
|
||||||
|
* Порядок внутри транзакции намеренно такой:
|
||||||
|
* 1. Сначала уменьшаем чужие likes_count для тех целей, где финальное состояние
|
||||||
|
* реакции этой цепочки было LIKE.
|
||||||
|
* 2. Сначала уменьшаем чужие replies_count для reply-блоков этой цепочки.
|
||||||
|
* 3. После этого удаляем локальные derived-state самой цепочки.
|
||||||
|
* 4. В конце удаляем blocks и blockchain_state.
|
||||||
|
*
|
||||||
|
* Это правильно потому, что агрегаты (`message_stats`) должны видеть исходные blocks
|
||||||
|
* и reactions_state на момент пересчёта. Если удалить blocks раньше, мы потеряем
|
||||||
|
* источник правды для корректного уменьшения счётчиков.
|
||||||
|
*
|
||||||
|
* Метод идемпотентен по смыслу:
|
||||||
|
* - если часть данных уже удалена раньше, повторный вызов просто удалит "0 строк";
|
||||||
|
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||||
|
*
|
||||||
|
* Отдельно важно:
|
||||||
|
* - здесь НЕТ удаления .bch/.tmp_bch;
|
||||||
|
* - здесь НЕТ повторной загрузки цепочки;
|
||||||
|
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||||
|
*/
|
||||||
|
public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException {
|
||||||
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("blockchainName is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
String login = resolveLoginForCleanup(c, blockchainName);
|
||||||
|
|
||||||
|
int likesAdjusted = decreaseForeignLikesCount(c, blockchainName);
|
||||||
|
int repliesAdjusted = decreaseForeignRepliesCount(c, blockchainName);
|
||||||
|
|
||||||
|
int deletedMessageStats = deleteMessageStatsForOwnTargets(c, blockchainName);
|
||||||
|
int deletedReactionsState = deleteReactionsStateForActorChain(c, blockchainName);
|
||||||
|
int deletedConnectionsState = deleteConnectionsStateForLogin(c, login);
|
||||||
|
int deletedUsersParams = deleteUsersParamsForLogin(c, login);
|
||||||
|
int deletedChannelNames = deleteChannelNamesForOwnerChain(c, blockchainName);
|
||||||
|
int deletedChat200State = deleteChat200StateForOwnerChain(c, blockchainName);
|
||||||
|
int deletedChat200Members = deleteChat200MembersForOwnerChain(c, blockchainName);
|
||||||
|
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||||
|
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
|
||||||
|
return new CleanupResult(
|
||||||
|
login,
|
||||||
|
likesAdjusted,
|
||||||
|
repliesAdjusted,
|
||||||
|
deletedMessageStats,
|
||||||
|
deletedReactionsState,
|
||||||
|
deletedConnectionsState,
|
||||||
|
deletedUsersParams,
|
||||||
|
deletedChannelNames,
|
||||||
|
deletedChat200State,
|
||||||
|
deletedChat200Members,
|
||||||
|
deletedBlocks,
|
||||||
|
deletedBlockchainState
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||||
|
throw new SQLException("Не удалось очистить blockchain для полного resync: " + blockchainName, e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login
|
||||||
|
FROM blockchain_state
|
||||||
|
WHERE blockchain_name = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, blockchainName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
String login = rs.getString("login");
|
||||||
|
if (login != null && !login.isBlank()) {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String loginFromName = loginFromBlockchainName(blockchainName);
|
||||||
|
if (loginFromName == null || loginFromName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Cannot derive login from blockchainName: " + blockchainName);
|
||||||
|
}
|
||||||
|
return loginFromName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO остаётся в модуле БД и не тянет зависимость на blockchain-utils модуль.
|
||||||
|
* Поэтому здесь локально повторяем минимальное правило имени chain:
|
||||||
|
* login + "-NNN".
|
||||||
|
*/
|
||||||
|
private String loginFromBlockchainName(String blockchainName) {
|
||||||
|
if (blockchainName == null) return null;
|
||||||
|
|
||||||
|
String s = blockchainName.trim();
|
||||||
|
if (s.length() <= BLOCKCHAIN_LOGIN_SUFFIX_LEN) return null;
|
||||||
|
|
||||||
|
int dashPos = s.length() - BLOCKCHAIN_LOGIN_SUFFIX_LEN;
|
||||||
|
if (s.charAt(dashPos) != '-') return null;
|
||||||
|
|
||||||
|
for (int i = dashPos + 1; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
if (ch < '0' || ch > '9') return null;
|
||||||
|
}
|
||||||
|
return s.substring(0, dashPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уменьшаем likes_count только для ЧУЖИХ целей.
|
||||||
|
*
|
||||||
|
* Логика:
|
||||||
|
* - если у удаляемой цепочки финальное состояние реакции на цель = LIKE,
|
||||||
|
* значит при полном удалении цепочки этот активный лайк исчезает;
|
||||||
|
* - значит у message_stats этой чужой цели нужно сделать -1;
|
||||||
|
* - для целей внутри этой же chain этого делать не нужно, потому что сами цели
|
||||||
|
* тоже будут удалены вместе с цепочкой.
|
||||||
|
*/
|
||||||
|
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE message_stats
|
||||||
|
SET likes_count = MAX(
|
||||||
|
0,
|
||||||
|
likes_count - (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE rs.from_bch_name = ?
|
||||||
|
AND rs.reaction_type = ?
|
||||||
|
AND rs.last_sub_type = ?
|
||||||
|
AND rs.to_login = message_stats.to_login
|
||||||
|
AND rs.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND rs.to_block_number = message_stats.to_block_number
|
||||||
|
AND rs.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND rs.to_bch_name <> ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE rs.from_bch_name = ?
|
||||||
|
AND rs.reaction_type = ?
|
||||||
|
AND rs.last_sub_type = ?
|
||||||
|
AND rs.to_login = message_stats.to_login
|
||||||
|
AND rs.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND rs.to_block_number = message_stats.to_block_number
|
||||||
|
AND rs.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND rs.to_bch_name <> ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уменьшаем replies_count только для ЧУЖИХ целей.
|
||||||
|
*
|
||||||
|
* Если reply этой цепочки ссылался на сообщение из другой цепочки,
|
||||||
|
* значит после удаления blocks этой цепочки чужой replies_count должен уменьшиться.
|
||||||
|
* Reply на собственные сообщения здесь игнорируем: целевая цепочка тоже будет удалена.
|
||||||
|
*/
|
||||||
|
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE message_stats
|
||||||
|
SET replies_count = MAX(
|
||||||
|
0,
|
||||||
|
replies_count - COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.bch_name = ?
|
||||||
|
AND b.msg_type = 1
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND b.to_login = message_stats.to_login
|
||||||
|
AND b.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND b.to_block_number = message_stats.to_block_number
|
||||||
|
AND b.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND b.to_bch_name <> ?
|
||||||
|
), 0)
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.bch_name = ?
|
||||||
|
AND b.msg_type = 1
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND b.to_login = message_stats.to_login
|
||||||
|
AND b.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND b.to_block_number = message_stats.to_block_number
|
||||||
|
AND b.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND b.to_bch_name <> ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статистика сообщений самой удаляемой цепочки после reset не нужна,
|
||||||
|
* потому что её цели исчезают вместе с chain source data.
|
||||||
|
*/
|
||||||
|
private int deleteMessageStatsForOwnTargets(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM message_stats
|
||||||
|
WHERE to_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactions_state хранит финальное состояние реакций АКТОРА.
|
||||||
|
* После удаления всей цепочки актор этой цепочки исчезает, поэтому
|
||||||
|
* достаточно удалить все строки по from_bch_name.
|
||||||
|
*/
|
||||||
|
private int deleteReactionsStateForActorChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM reactions_state
|
||||||
|
WHERE from_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* connections_state — текущее состояние связей, выставленных этим login.
|
||||||
|
* Чистим по владельцу состояния.
|
||||||
|
*/
|
||||||
|
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
""", login);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users_params — актуальные параметры, собранные из блоков пользователя.
|
||||||
|
*/
|
||||||
|
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM users_params
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
""", login);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каналы принадлежат owner_bch_name.
|
||||||
|
*/
|
||||||
|
private int deleteChannelNamesForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int deleteChat200StateForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM chat200_state
|
||||||
|
WHERE owner_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int deleteChat200MembersForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM chat200_members_state
|
||||||
|
WHERE owner_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* blocks удаляем в конце, потому что до этого шага они нужны как источник правды
|
||||||
|
* для уменьшения replies_count.
|
||||||
|
*/
|
||||||
|
private int deleteBlocksForChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM blocks
|
||||||
|
WHERE bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* blockchain_state удаляем после blocks, чтобы не нарушать FK-связь blocks -> blockchain_state.
|
||||||
|
*/
|
||||||
|
private int deleteBlockchainStateForChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM blockchain_state
|
||||||
|
WHERE blockchain_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, value);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Технический результат cleanup-операции.
|
||||||
|
* Нужен для будущего логирования и ручной диагностики resync-flow.
|
||||||
|
*/
|
||||||
|
public record CleanupResult(
|
||||||
|
String login,
|
||||||
|
int likesAdjustedRows,
|
||||||
|
int repliesAdjustedRows,
|
||||||
|
int deletedMessageStatsRows,
|
||||||
|
int deletedReactionsStateRows,
|
||||||
|
int deletedConnectionsStateRows,
|
||||||
|
int deletedUsersParamsRows,
|
||||||
|
int deletedChannelNamesRows,
|
||||||
|
int deletedChat200StateRows,
|
||||||
|
int deletedChat200MembersRows,
|
||||||
|
int deletedBlocksRows,
|
||||||
|
int deletedBlockchainStateRows
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import shine.db.SqliteDbController;
|
|||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public final class BlockchainStateDAO {
|
public final class BlockchainStateDAO {
|
||||||
|
|
||||||
@ -53,6 +55,39 @@ public final class BlockchainStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Получить все blockchain_state записи. */
|
||||||
|
public List<BlockchainStateEntry> listAll() throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listAll(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Получить все blockchain_state записи с внешним соединением. Соединение НЕ закрывает. */
|
||||||
|
public List<BlockchainStateEntry> listAll(Connection c) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
blockchain_name,
|
||||||
|
login,
|
||||||
|
blockchain_key,
|
||||||
|
size_limit,
|
||||||
|
file_size_bytes,
|
||||||
|
last_block_number,
|
||||||
|
last_block_hash,
|
||||||
|
updated_at_ms
|
||||||
|
FROM blockchain_state
|
||||||
|
ORDER BY blockchain_name COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<BlockchainStateEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql);
|
||||||
|
ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** UPSERT без внешнего соединения. Сам открывает/закрывает. */
|
/** UPSERT без внешнего соединения. Сам открывает/закрывает. */
|
||||||
public void upsert(BlockchainStateEntry e) throws SQLException {
|
public void upsert(BlockchainStateEntry e) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
@ -103,6 +138,54 @@ public final class BlockchainStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строгая вставка state только если записи ещё нет.
|
||||||
|
*
|
||||||
|
* Нужна для recovery / resync:
|
||||||
|
* - identity пользователя уже может существовать в solana_users;
|
||||||
|
* - в таком случае нам надо восстановить только blockchain_state;
|
||||||
|
* - если запись уже есть, метод просто ничего не меняет.
|
||||||
|
*/
|
||||||
|
public boolean insertIfMissing(Connection c, BlockchainStateEntry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO blockchain_state (
|
||||||
|
blockchain_name,
|
||||||
|
login,
|
||||||
|
blockchain_key,
|
||||||
|
size_limit,
|
||||||
|
file_size_bytes,
|
||||||
|
last_block_number,
|
||||||
|
last_block_hash,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(blockchain_name) DO NOTHING
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
|
||||||
|
ps.setString(i++, e.getBlockchainName());
|
||||||
|
ps.setString(i++, nn(e.getLogin()));
|
||||||
|
ps.setString(i++, nn(e.getBlockchainKey()));
|
||||||
|
|
||||||
|
ps.setLong(i++, e.getSizeLimit());
|
||||||
|
ps.setLong(i++, e.getFileSizeBytes());
|
||||||
|
|
||||||
|
ps.setInt(i++, e.getLastBlockNumber());
|
||||||
|
setBytesNullable(ps, i++, e.getLastBlockHash());
|
||||||
|
|
||||||
|
ps.setLong(i++, e.getUpdatedAtMs());
|
||||||
|
|
||||||
|
return ps.executeUpdate() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean insertIfMissing(BlockchainStateEntry e) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return insertIfMissing(c, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit.
|
* Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -39,7 +39,9 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_
|
|||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_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 server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
||||||
@ -104,8 +106,10 @@ 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_CallDeliveryReport_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
|
||||||
@ -113,6 +117,8 @@ 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_Ping_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -161,6 +167,7 @@ public final class JsonHandlerRegistry {
|
|||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
|
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||||
@ -193,6 +200,8 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- system ---
|
// --- system ---
|
||||||
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("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()),
|
||||||
@ -236,6 +245,7 @@ public final class JsonHandlerRegistry {
|
|||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||||
|
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||||
@ -268,6 +278,8 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- system ---
|
// --- system ---
|
||||||
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("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),
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.sync.BlockchainResyncGuard;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||||
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 server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||||
@ -70,6 +71,21 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq;
|
Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq;
|
||||||
|
|
||||||
String blockchainName = req.getBlockchainName();
|
String blockchainName = req.getBlockchainName();
|
||||||
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
|
return error(req, WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, "");
|
||||||
|
}
|
||||||
|
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||||
|
BlockchainStateEntry currentState = null;
|
||||||
|
try {
|
||||||
|
currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||||
|
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||||
|
return error(req, 423, "chain_resync_in_progress",
|
||||||
|
lastNum,
|
||||||
|
lastHash);
|
||||||
|
}
|
||||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
try {
|
try {
|
||||||
@ -126,7 +142,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String humanMessage(String code) {
|
private static String humanMessage(String code) {
|
||||||
if (code == null) return "Ошибка добавления блока"; return switch (code) {
|
if (code == null) return "Ошибка добавления блока";
|
||||||
|
return switch (code) {
|
||||||
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
||||||
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
||||||
case "db_error" -> "Ошибка базы данных";
|
case "db_error" -> "Ошибка базы данных";
|
||||||
@ -150,6 +167,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,18 +11,32 @@ import shine.db.entities.ChannelNameStateEntry;
|
|||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockchainWriter — запись блока в DB + обновление state + запись в файл.
|
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||||
*
|
*
|
||||||
* ВАЖНО:
|
* Текущая схема:
|
||||||
* - Это минимальный рабочий вариант под новый формат.
|
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||||
* - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом.
|
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||||
|
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||||
|
* 4) выполняем одну SQL-транзакцию;
|
||||||
|
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||||
|
* 6) убираем временные файлы.
|
||||||
|
*
|
||||||
|
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||||
|
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||||
|
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||||
*/
|
*/
|
||||||
public final class BlockchainWriter {
|
public final class BlockchainWriter {
|
||||||
|
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
private final BlocksDAO blocksDAO;
|
private final BlocksDAO blocksDAO;
|
||||||
private final BlockchainStateDAO stateDAO;
|
private final BlockchainStateDAO stateDAO;
|
||||||
private final ChannelNameStateDAO channelNameStateDAO;
|
private final ChannelNameStateDAO channelNameStateDAO;
|
||||||
@ -47,7 +61,13 @@ public final class BlockchainWriter {
|
|||||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
|
byte[] blockBytes = block.toBytes();
|
||||||
|
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||||
|
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||||
|
|
||||||
|
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||||
|
|
||||||
|
boolean committed = false;
|
||||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
try {
|
try {
|
||||||
@ -57,7 +77,7 @@ public final class BlockchainWriter {
|
|||||||
// 2) update state
|
// 2) update state
|
||||||
st.setLastBlockNumber(block.blockNumber);
|
st.setLastBlockNumber(block.blockNumber);
|
||||||
st.setLastBlockHash(block.getHash32());
|
st.setLastBlockHash(block.getHash32());
|
||||||
st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length);
|
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||||
st.setUpdatedAtMs(nowMs);
|
st.setUpdatedAtMs(nowMs);
|
||||||
|
|
||||||
stateDAO.upsert(c, st);
|
stateDAO.upsert(c, st);
|
||||||
@ -72,18 +92,80 @@ public final class BlockchainWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
|
committed = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
try {
|
||||||
if (e instanceof SQLException se) throw se;
|
c.rollback();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!committed) {
|
||||||
|
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof SQLException se) {
|
||||||
|
throw se;
|
||||||
|
}
|
||||||
throw new SQLException("appendBlockAndState failed", e);
|
throw new SQLException("appendBlockAndState failed", e);
|
||||||
} finally {
|
} finally {
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
try {
|
||||||
|
c.setAutoCommit(true);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) append to file (минимально: просто дописать)
|
// 3) После commit — атомарно подменяем основной файл.
|
||||||
// Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут.
|
try {
|
||||||
String fileName = fs.buildBlockchainFileName(blockchainName);
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
fs.addDataToFile(fileName, block.toBytes());
|
} catch (RuntimeException e) {
|
||||||
|
// marker/check/tmp оставляем для startup-recovery
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) После успешной подмены — чистим временные артефакты.
|
||||||
|
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||||
|
byte[] base;
|
||||||
|
try {
|
||||||
|
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||||
|
base = fs.readBlockchain(blockchainName);
|
||||||
|
} else {
|
||||||
|
base = new byte[0];
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] out = new byte[base.length + blockBytes.length];
|
||||||
|
System.arraycopy(base, 0, out, 0, base.length);
|
||||||
|
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||||
|
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||||
|
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||||
|
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteQuietly(DeleteAction action) {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface DeleteAction {
|
||||||
|
void run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
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.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetBlockchainBlock — публичное чтение одного конкретного блока по имени цепочки и номеру.
|
||||||
|
*/
|
||||||
|
public final class Net_GetBlockchainBlock_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetBlockchainBlock_Handler.class);
|
||||||
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetBlockchainBlock_Request req = (Net_GetBlockchainBlock_Request) baseRequest;
|
||||||
|
|
||||||
|
String blockchainName = req.getBlockchainName() == null ? "" : req.getBlockchainName().trim();
|
||||||
|
if (blockchainName.isEmpty() || req.getBlockNumber() < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.BAD_REQUEST,
|
||||||
|
"BAD_FIELDS",
|
||||||
|
"Некорректные поля: blockchainName, blockNumber"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BlockEntry block = blocksDAO.getByNumber(blockchainName, req.getBlockNumber());
|
||||||
|
if (block == null || block.getBlockBytes() == null) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.NOT_FOUND,
|
||||||
|
"BLOCK_NOT_FOUND",
|
||||||
|
"Блок не найден"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetBlockchainBlock_Response resp = new Net_GetBlockchainBlock_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setBlockchainName(block.getBchName());
|
||||||
|
resp.setBlockNumber(block.getBlockNumber());
|
||||||
|
resp.setBlockHash(toHex(block.getBlockHash()));
|
||||||
|
resp.setBlockBytesB64(Base64Ws.encode(block.getBlockBytes()));
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Internal error GetBlockchainBlock blockchainName={} blockNumber={}",
|
||||||
|
blockchainName, req.getBlockNumber(), e);
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.INTERNAL_ERROR,
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetBlockchainBlock", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex(byte[] bytes) {
|
||||||
|
if (bytes == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(Character.forDigit((b >>> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public final class Net_GetBlockchainBlock_Request extends Net_Request {
|
||||||
|
|
||||||
|
private String blockchainName;
|
||||||
|
private int blockNumber;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public final class Net_GetBlockchainBlock_Response extends Net_Response {
|
||||||
|
|
||||||
|
private String blockchainName;
|
||||||
|
private int blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
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_ListBlockchainHeads_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListBlockchainHeads — получить краткую сводку по всем blockchain_state.
|
||||||
|
* Используется для межсерверной сверки heads перед догоняющей синхронизацией.
|
||||||
|
*/
|
||||||
|
public class Net_ListBlockchainHeads_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_ListBlockchainHeads_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_ListBlockchainHeads_Request req = (Net_ListBlockchainHeads_Request) baseRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<BlockchainStateEntry> states = BlockchainStateDAO.getInstance().listAll();
|
||||||
|
|
||||||
|
Net_ListBlockchainHeads_Response resp = new Net_ListBlockchainHeads_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
|
||||||
|
List<Net_ListBlockchainHeads_Response.Item> items = new ArrayList<>(states.size());
|
||||||
|
for (BlockchainStateEntry state : states) {
|
||||||
|
Net_ListBlockchainHeads_Response.Item item = new Net_ListBlockchainHeads_Response.Item();
|
||||||
|
item.setBlockchainName(state.getBlockchainName());
|
||||||
|
item.setLastBlockNumber(state.getLastBlockNumber());
|
||||||
|
item.setLastBlockHash(toHex32(state.getLastBlockHash()));
|
||||||
|
item.setFileSizeBytes(state.getFileSizeBytes());
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
resp.setBlockchains(items);
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Internal error ListBlockchainHeads", e);
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.INTERNAL_ERROR,
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при ListBlockchainHeads", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex32(byte[] bytes32) {
|
||||||
|
byte[] b = bytes32;
|
||||||
|
if (b == null || b.length != 32) {
|
||||||
|
b = new byte[32];
|
||||||
|
}
|
||||||
|
final char[] hex = "0123456789abcdef".toCharArray();
|
||||||
|
char[] out = new char[64];
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
int v = b[i] & 0xFF;
|
||||||
|
out[i * 2] = hex[v >>> 4];
|
||||||
|
out[i * 2 + 1] = hex[v & 0x0F];
|
||||||
|
}
|
||||||
|
return new String(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пустой запрос для получения списка heads всех blockchain_state.
|
||||||
|
*/
|
||||||
|
public class Net_ListBlockchainHeads_Request extends Net_Request {
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_ListBlockchainHeads_Response extends Net_Response {
|
||||||
|
|
||||||
|
public static final class Item {
|
||||||
|
private String blockchainName;
|
||||||
|
private int lastBlockNumber;
|
||||||
|
private String lastBlockHash;
|
||||||
|
private long fileSizeBytes;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public int getLastBlockNumber() { return lastBlockNumber; }
|
||||||
|
public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; }
|
||||||
|
|
||||||
|
public String getLastBlockHash() { return lastBlockHash; }
|
||||||
|
public void setLastBlockHash(String lastBlockHash) { this.lastBlockHash = lastBlockHash; }
|
||||||
|
|
||||||
|
public long getFileSizeBytes() { return fileSizeBytes; }
|
||||||
|
public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Item> blockchains = new ArrayList<>();
|
||||||
|
|
||||||
|
public List<Item> getBlockchains() { return blockchains; }
|
||||||
|
public void setBlockchains(List<Item> blockchains) { this.blockchains = blockchains; }
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory guard для цепочек, которые сейчас находятся в полном resync.
|
||||||
|
*
|
||||||
|
* Задача guard-а:
|
||||||
|
* - не давать обычному AddBlock писать в цепочку, пока она пересобирается;
|
||||||
|
* - позволять внутреннему resync-потоку безопасно вызывать тот же AddBlock-путь
|
||||||
|
* через thread-local bypass.
|
||||||
|
*/
|
||||||
|
public final class BlockchainResyncGuard {
|
||||||
|
|
||||||
|
private static final Set<String> ACTIVE = ConcurrentHashMap.newKeySet();
|
||||||
|
private static final ThreadLocal<Set<String>> BYPASS = ThreadLocal.withInitial(HashSet::new);
|
||||||
|
|
||||||
|
private BlockchainResyncGuard() {}
|
||||||
|
|
||||||
|
public static boolean tryBegin(String blockchainName) {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) return false;
|
||||||
|
return ACTIVE.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void end(String blockchainName) {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) return;
|
||||||
|
ACTIVE.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isBlockedForExternalAddBlock(String blockchainName) {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) return false;
|
||||||
|
if (isBypassed(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ACTIVE.contains(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T withBypass(String blockchainName, ThrowingSupplier<T> supplier) throws Exception {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) {
|
||||||
|
return supplier.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> bypassSet = BYPASS.get();
|
||||||
|
bypassSet.add(key);
|
||||||
|
try {
|
||||||
|
return supplier.get();
|
||||||
|
} finally {
|
||||||
|
bypassSet.remove(key);
|
||||||
|
if (bypassSet.isEmpty()) {
|
||||||
|
BYPASS.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void withBypass(String blockchainName, ThrowingRunnable runnable) throws Exception {
|
||||||
|
withBypass(blockchainName, () -> {
|
||||||
|
runnable.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBypassed(String blockchainName) {
|
||||||
|
return BYPASS.get().contains(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ThrowingSupplier<T> {
|
||||||
|
T get() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный клиент для межсерверных JSON-op запросов по WSS.
|
||||||
|
*/
|
||||||
|
public final class RemoteBlockchainSyncClient {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public List<RemoteBlockchainHead> listBlockchainHeads(String serverAddressRaw) throws Exception {
|
||||||
|
JsonNode response = send(serverAddressRaw, """
|
||||||
|
{
|
||||||
|
"op":"ListBlockchainHeads",
|
||||||
|
"requestId":%s,
|
||||||
|
"payload":{}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
int status = response.path("status").asInt(500);
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
throw new IllegalStateException("ListBlockchainHeads failed: status=" + status + " code=" + errorCode(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RemoteBlockchainHead> result = new ArrayList<>();
|
||||||
|
JsonNode items = response.path("payload").path("blockchains");
|
||||||
|
if (!items.isArray()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (JsonNode item : items) {
|
||||||
|
result.add(new RemoteBlockchainHead(
|
||||||
|
item.path("blockchainName").asText(""),
|
||||||
|
item.path("lastBlockNumber").asInt(-1),
|
||||||
|
item.path("lastBlockHash").asText(""),
|
||||||
|
item.path("fileSizeBytes").asLong(0L)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
|
||||||
|
JsonNode response = send(serverAddressRaw, """
|
||||||
|
{
|
||||||
|
"op":"GetBlockchainBlock",
|
||||||
|
"requestId":%s,
|
||||||
|
"payload":{
|
||||||
|
"blockchainName":%s,
|
||||||
|
"blockNumber":%d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted("%s", safeBlockchainName, blockNumber));
|
||||||
|
|
||||||
|
int status = response.path("status").asInt(500);
|
||||||
|
if (status == 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
throw new IllegalStateException("GetBlockchainBlock failed: status=" + status + " code=" + errorCode(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode payload = response.path("payload");
|
||||||
|
return new RemoteBlockchainBlock(
|
||||||
|
payload.path("blockchainName").asText(blockchainName),
|
||||||
|
payload.path("blockNumber").asInt(blockNumber),
|
||||||
|
payload.path("blockHash").asText(""),
|
||||||
|
payload.path("blockBytesB64").asText("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||||
|
String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID());
|
||||||
|
String json = jsonTemplate.formatted(requestId);
|
||||||
|
String wsUrl = buildWsUrl(serverAddressRaw);
|
||||||
|
if (wsUrl == null) {
|
||||||
|
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
||||||
|
CountDownLatch openLatch = new CountDownLatch(1);
|
||||||
|
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
||||||
|
|
||||||
|
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.buildAsync(URI.create(wsUrl), listener)
|
||||||
|
.get(8, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
if (!openLatch.await(8, TimeUnit.SECONDS)) {
|
||||||
|
tryAbort(webSocket);
|
||||||
|
throw new TimeoutException("WS open timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
|
||||||
|
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
|
||||||
|
tryAbort(webSocket);
|
||||||
|
return MAPPER.readTree(responseJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String errorCode(JsonNode response) {
|
||||||
|
String code = response.path("code").asText("");
|
||||||
|
if (!code.isBlank()) return code;
|
||||||
|
return response.path("error").asText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildWsUrl(String serverAddressRaw) {
|
||||||
|
String host = normalizeHostLike(serverAddressRaw);
|
||||||
|
if (host == null) return null;
|
||||||
|
return "wss://" + host + "/ws";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHostLike(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String raw = value.trim();
|
||||||
|
if (raw.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
|
||||||
|
URI uri = URI.create(withScheme);
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (host == null || host.isBlank()) return null;
|
||||||
|
return host.trim().toLowerCase(Locale.ROOT);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String cleaned = raw
|
||||||
|
.replaceFirst("^[a-zA-Z]+://", "")
|
||||||
|
.replaceFirst("/.*$", "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
return cleaned.isEmpty() ? null : cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tryAbort(WebSocket webSocket) {
|
||||||
|
try {
|
||||||
|
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
webSocket.abort();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RemoteBlockchainHead(
|
||||||
|
String blockchainName,
|
||||||
|
int lastBlockNumber,
|
||||||
|
String lastBlockHash,
|
||||||
|
long fileSizeBytes
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RemoteBlockchainBlock(
|
||||||
|
String blockchainName,
|
||||||
|
int blockNumber,
|
||||||
|
String blockHash,
|
||||||
|
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 final CompletableFuture<String> responseFuture;
|
||||||
|
private final CountDownLatch openLatch;
|
||||||
|
private final StringBuilder textBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
|
||||||
|
this.responseFuture = responseFuture;
|
||||||
|
this.openLatch = openLatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocket webSocket) {
|
||||||
|
openLatch.countDown();
|
||||||
|
webSocket.request(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||||
|
textBuffer.append(data);
|
||||||
|
if (last && !responseFuture.isDone()) {
|
||||||
|
responseFuture.complete(textBuffer.toString());
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
|
||||||
|
webSocket.request(1);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
|
if (!responseFuture.isDone()) {
|
||||||
|
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
|
log.warn("Remote sync websocket error: {}", String.valueOf(error));
|
||||||
|
if (!responseFuture.isDone()) {
|
||||||
|
responseFuture.completeExceptionally(error);
|
||||||
|
}
|
||||||
|
openLatch.countDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup-recovery для цепочек, которые были помечены как resync-pending.
|
||||||
|
*
|
||||||
|
* Правило простое:
|
||||||
|
* - marker-file означает, что chain должен быть пересобран заново;
|
||||||
|
* - мы не пытаемся продолжать с середины;
|
||||||
|
* - если recovery не завершён, обычная работа сервера не стартует.
|
||||||
|
*/
|
||||||
|
public final class BlockchainResyncRecoveryOnStartup {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class);
|
||||||
|
private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient();
|
||||||
|
|
||||||
|
private BlockchainResyncRecoveryOnStartup() {}
|
||||||
|
|
||||||
|
public static void runRecoveryOrThrow() {
|
||||||
|
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
||||||
|
ensureDirExists(dataDir);
|
||||||
|
|
||||||
|
List<Path> markers = listMarkerFiles(dataDir);
|
||||||
|
if (markers.isEmpty()) {
|
||||||
|
log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size());
|
||||||
|
for (Path marker : markers) {
|
||||||
|
recoverSingleMarkerOrThrow(marker);
|
||||||
|
}
|
||||||
|
log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void recoverSingleMarkerOrThrow(Path markerPath) {
|
||||||
|
String fileName = markerPath.getFileName().toString();
|
||||||
|
String blockchainName = extractBlockchainName(fileName);
|
||||||
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
|
throw new IllegalStateException("Bad resync marker name: " + fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> meta = parseMarker(markerPath);
|
||||||
|
String partnerLogin = normalize(meta.get("partnerLogin"));
|
||||||
|
String partnerAddress = normalize(meta.get("partnerAddress"));
|
||||||
|
if (partnerAddress == null) {
|
||||||
|
throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}",
|
||||||
|
blockchainName, partnerLogin, partnerAddress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads = REMOTE.listBlockchainHeads(partnerAddress);
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
|
||||||
|
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
|
"Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress));
|
||||||
|
|
||||||
|
SyncServerEntry partner = new SyncServerEntry(
|
||||||
|
partnerLogin == null ? "" : partnerLogin,
|
||||||
|
partnerAddress,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
|
||||||
|
PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Path> listMarkerFiles(Path dataDir) {
|
||||||
|
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
|
||||||
|
return stream(ds).toList();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.stream.Stream<Path> stream(DirectoryStream<Path> ds) {
|
||||||
|
return java.util.stream.StreamSupport.stream(ds.spliterator(), false)
|
||||||
|
.filter(Files::isRegularFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseMarker(Path path) {
|
||||||
|
try {
|
||||||
|
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
for (String line : lines) {
|
||||||
|
if (line == null) continue;
|
||||||
|
String s = line.trim();
|
||||||
|
if (s.isEmpty() || s.startsWith("#")) continue;
|
||||||
|
int idx = s.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
String key = s.substring(0, idx).trim();
|
||||||
|
String value = s.substring(idx + 1).trim();
|
||||||
|
result.put(key, value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot read resync marker: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureDirExists(Path dir) {
|
||||||
|
try {
|
||||||
|
if (!Files.exists(dir)) {
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot create data dir: " + dir, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractBlockchainName(String fileName) {
|
||||||
|
if (fileName == null) return null;
|
||||||
|
String s = fileName.trim();
|
||||||
|
if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim();
|
||||||
|
return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,468 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
|
import shine.db.dao.BlockchainResyncCleanupDAO;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.SyncServersDAO;
|
||||||
|
import shine.db.dao.UserCreateDAO;
|
||||||
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
import server.sync.BlockchainResyncGuard;
|
||||||
|
import utils.files.FileStoreUtil;
|
||||||
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плановый межсерверный sync блокчейнов.
|
||||||
|
* Сейчас реализует:
|
||||||
|
* - догоняющую синхронизацию отсутствующего хвоста;
|
||||||
|
* - базовый full-resync при divergence, если удалённая цепочка сильнее.
|
||||||
|
*/
|
||||||
|
public final class PeriodicBlockchainSyncService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PeriodicBlockchainSyncService.class);
|
||||||
|
private static final long PERIOD_HOURS = 12L;
|
||||||
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||||
|
@Override
|
||||||
|
public Thread newThread(Runnable r) {
|
||||||
|
Thread t = new Thread(r, "periodic-blockchain-sync");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient();
|
||||||
|
private static final Net_AddBlock_Handler ADD_BLOCK_HANDLER = new Net_AddBlock_Handler();
|
||||||
|
private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance();
|
||||||
|
private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance();
|
||||||
|
private static final UserCreateDAO USER_CREATE_DAO = UserCreateDAO.getInstance();
|
||||||
|
private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance();
|
||||||
|
private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance();
|
||||||
|
private static final SolanaUsersDAO SOLANA_USERS_DAO = SolanaUsersDAO.getInstance();
|
||||||
|
private static final String CONFIG_IMPORT_PROFILE_FROM_PARTNER = "sync.importUserProfileFromPartner.enabled";
|
||||||
|
|
||||||
|
private PeriodicBlockchainSyncService() {}
|
||||||
|
|
||||||
|
public static void startOrLog() {
|
||||||
|
if (!STARTED.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EXECUTOR.scheduleWithFixedDelay(
|
||||||
|
PeriodicBlockchainSyncService::runCycleSafe,
|
||||||
|
0L,
|
||||||
|
PERIOD_HOURS,
|
||||||
|
TimeUnit.HOURS
|
||||||
|
);
|
||||||
|
log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runCycleSafe() {
|
||||||
|
try {
|
||||||
|
runCycle();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Periodic blockchain sync failed unexpectedly", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runCycle() throws Exception {
|
||||||
|
SyncServersBootstrapService.refreshFromSolanaOrLog();
|
||||||
|
List<SyncServerEntry> partners = SYNC_SERVERS_DAO.listAll();
|
||||||
|
if (partners.isEmpty()) {
|
||||||
|
log.info("Periodic blockchain sync skipped: sync_servers is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (SyncServerEntry partner : partners) {
|
||||||
|
if (partner == null) continue;
|
||||||
|
try {
|
||||||
|
syncPartner(partner);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Periodic blockchain sync partner failed: login={} reason={}",
|
||||||
|
partner.getLogin(), String.valueOf(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void syncPartner(SyncServerEntry partner) throws Exception {
|
||||||
|
String partnerLogin = normalize(partner.getLogin());
|
||||||
|
if (partnerLogin == null) return;
|
||||||
|
|
||||||
|
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads =
|
||||||
|
REMOTE.listBlockchainHeads(partner.getServerAddress());
|
||||||
|
|
||||||
|
for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) {
|
||||||
|
if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockchainStateEntry localState = STATE_DAO.getByBlockchainName(remoteHead.blockchainName());
|
||||||
|
if (localState == null) {
|
||||||
|
syncMissingTail(partner, remoteHead, -1, "");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int localLast = localState.getLastBlockNumber();
|
||||||
|
String localHash = toHex32(localState.getLastBlockHash());
|
||||||
|
|
||||||
|
if (localLast < remoteHead.lastBlockNumber()) {
|
||||||
|
syncMissingTail(partner, remoteHead, localLast, localHash);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localLast == remoteHead.lastBlockNumber()) {
|
||||||
|
if (localHash.equalsIgnoreCase(remoteHead.lastBlockHash())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isRemoteStronger(localState, remoteHead)) {
|
||||||
|
log.warn("Periodic blockchain sync: divergence detected, remote chain is stronger, starting full resync. partner={} blockchainName={} localLast={} localHash={} remoteHash={} localSize={} remoteSize={}",
|
||||||
|
partnerLogin,
|
||||||
|
remoteHead.blockchainName(),
|
||||||
|
localLast,
|
||||||
|
localHash,
|
||||||
|
remoteHead.lastBlockHash(),
|
||||||
|
localState.getFileSizeBytes(),
|
||||||
|
remoteHead.fileSizeBytes());
|
||||||
|
resyncFromScratch(partner, remoteHead);
|
||||||
|
} else {
|
||||||
|
log.info("Periodic blockchain sync skipped: local chain is stronger or equal. partner={} blockchainName={} localLast={} remoteLast={} localSize={} remoteSize={}",
|
||||||
|
partnerLogin,
|
||||||
|
remoteHead.blockchainName(),
|
||||||
|
localLast,
|
||||||
|
remoteHead.lastBlockNumber(),
|
||||||
|
localState.getFileSizeBytes(),
|
||||||
|
remoteHead.fileSizeBytes());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Periodic blockchain sync skipped: local chain is not weaker. partner={} blockchainName={} localLast={} remoteLast={}",
|
||||||
|
partnerLogin, remoteHead.blockchainName(), localLast, remoteHead.lastBlockNumber());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void syncMissingTail(
|
||||||
|
SyncServerEntry partner,
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead,
|
||||||
|
int localLast,
|
||||||
|
String localHash
|
||||||
|
) throws Exception {
|
||||||
|
String partnerLogin = normalize(partner.getLogin());
|
||||||
|
if (!ensureLocalChainExists(partner, remoteHead.blockchainName())) {
|
||||||
|
log.warn("Periodic blockchain sync: cannot prepare local chain. partner={} blockchainName={}",
|
||||||
|
partnerLogin, remoteHead.blockchainName());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fromBlockNumber = Math.max(localLast + 1, 0);
|
||||||
|
for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
||||||
|
REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
|
||||||
|
if (remoteBlock == null) {
|
||||||
|
log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
||||||
|
partnerLogin, remoteHead.blockchainName(), blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalAddBlockApplyResult result = applyBlockLocally(remoteBlock, blockNumber == 0 ? "" : localHash);
|
||||||
|
if (!result.ok()) {
|
||||||
|
if ("bad_prev_hash".equalsIgnoreCase(result.code()) || "bad_block_number".equalsIgnoreCase(result.code())) {
|
||||||
|
log.warn("Periodic blockchain sync: divergence detected during replay, starting full resync. partner={} blockchainName={} blockNumber={} code={}",
|
||||||
|
partnerLogin, remoteHead.blockchainName(), blockNumber, result.code());
|
||||||
|
resyncFromScratch(partner, remoteHead);
|
||||||
|
} else {
|
||||||
|
log.warn("Periodic blockchain sync: local AddBlock rejected remote block. partner={} blockchainName={} blockNumber={} code={} message={}",
|
||||||
|
partnerLogin, remoteHead.blockchainName(), blockNumber, result.code(), result.message());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localHash = result.serverLastHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Periodic blockchain sync ok: partner={} blockchainName={} from={} to={}",
|
||||||
|
partnerLogin, remoteHead.blockchainName(), fromBlockNumber, remoteHead.lastBlockNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void resyncFromScratch(
|
||||||
|
SyncServerEntry partner,
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead
|
||||||
|
) throws Exception {
|
||||||
|
if (partner == null || remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String blockchainName = remoteHead.blockchainName();
|
||||||
|
String partnerLogin = normalize(partner.getLogin());
|
||||||
|
|
||||||
|
if (!BlockchainResyncGuard.tryBegin(blockchainName)) {
|
||||||
|
log.warn("Blockchain resync skipped: already in progress for blockchainName={}", blockchainName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String markerContent = """
|
||||||
|
blockchainName=%s
|
||||||
|
partnerLogin=%s
|
||||||
|
partnerAddress=%s
|
||||||
|
remoteLastBlockNumber=%d
|
||||||
|
remoteLastBlockHash=%s
|
||||||
|
remoteFileSizeBytes=%d
|
||||||
|
startedAtMs=%d
|
||||||
|
""".formatted(
|
||||||
|
blockchainName,
|
||||||
|
partnerLogin == null ? "" : partnerLogin,
|
||||||
|
partner.getServerAddress() == null ? "" : partner.getServerAddress(),
|
||||||
|
remoteHead.lastBlockNumber(),
|
||||||
|
remoteHead.lastBlockHash(),
|
||||||
|
remoteHead.fileSizeBytes(),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
|
||||||
|
FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent);
|
||||||
|
|
||||||
|
ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName);
|
||||||
|
chainLock.lock();
|
||||||
|
boolean success = false;
|
||||||
|
try {
|
||||||
|
BlockchainResyncCleanupDAO.CleanupResult cleanup =
|
||||||
|
RESYNC_CLEANUP_DAO.cleanupBlockchainForFullResync(blockchainName);
|
||||||
|
log.info("Blockchain resync cleanup finished: blockchainName={} login={} likesAdjusted={} repliesAdjusted={} deletedBlocks={} deletedState={}",
|
||||||
|
blockchainName,
|
||||||
|
cleanup.login(),
|
||||||
|
cleanup.likesAdjustedRows(),
|
||||||
|
cleanup.repliesAdjustedRows(),
|
||||||
|
cleanup.deletedBlocksRows(),
|
||||||
|
cleanup.deletedBlockchainStateRows());
|
||||||
|
|
||||||
|
FILE_STORE.deleteBlockchainFileIfExists(blockchainName);
|
||||||
|
FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName);
|
||||||
|
|
||||||
|
if (!ensureLocalChainExists(partner, blockchainName)) {
|
||||||
|
throw new IllegalStateException("failed to recreate local chain state for " + blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean replayOk = replayRemoteChainFromStart(partner, remoteHead);
|
||||||
|
if (!replayOk) {
|
||||||
|
throw new IllegalStateException("failed to replay remote chain for " + blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName);
|
||||||
|
success = true;
|
||||||
|
log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}",
|
||||||
|
partnerLogin, blockchainName, remoteHead.lastBlockNumber());
|
||||||
|
} finally {
|
||||||
|
chainLock.unlock();
|
||||||
|
BlockchainResyncGuard.end(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new IllegalStateException("Blockchain resync did not complete for " + blockchainName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean replayRemoteChainFromStart(
|
||||||
|
SyncServerEntry partner,
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead
|
||||||
|
) throws Exception {
|
||||||
|
String blockchainName = remoteHead.blockchainName();
|
||||||
|
String partnerLogin = normalize(partner.getLogin());
|
||||||
|
|
||||||
|
return BlockchainResyncGuard.withBypass(blockchainName, () -> {
|
||||||
|
String localPrevHash = "";
|
||||||
|
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
||||||
|
REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber);
|
||||||
|
if (remoteBlock == null) {
|
||||||
|
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
||||||
|
partnerLogin, blockchainName, blockNumber);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalAddBlockApplyResult result = applyBlockLocally(remoteBlock, localPrevHash);
|
||||||
|
if (!result.ok()) {
|
||||||
|
log.warn("Blockchain resync: AddBlock rejected replay block. partner={} blockchainName={} blockNumber={} code={} message={}",
|
||||||
|
partnerLogin, blockchainName, blockNumber, result.code(), result.message());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
localPrevHash = result.serverLastHash();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isRemoteStronger(BlockchainStateEntry localState,
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead) {
|
||||||
|
if (localState == null || remoteHead == null) return false;
|
||||||
|
|
||||||
|
int localLast = localState.getLastBlockNumber();
|
||||||
|
int remoteLast = remoteHead.lastBlockNumber();
|
||||||
|
if (remoteLast != localLast) {
|
||||||
|
return remoteLast > localLast;
|
||||||
|
}
|
||||||
|
|
||||||
|
long localSize = localState.getFileSizeBytes();
|
||||||
|
long remoteSize = remoteHead.fileSizeBytes();
|
||||||
|
if (remoteSize != localSize) {
|
||||||
|
return remoteSize > localSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
String localHash = toHex32(localState.getLastBlockHash()).toLowerCase(Locale.ROOT);
|
||||||
|
String remoteHash = normalizeHex64(remoteHead.lastBlockHash());
|
||||||
|
return remoteHash.compareTo(localHash) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalAddBlockApplyResult applyBlockLocally(
|
||||||
|
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock,
|
||||||
|
String prevHash
|
||||||
|
) {
|
||||||
|
Net_AddBlock_Request req = new Net_AddBlock_Request();
|
||||||
|
req.setOp("AddBlock");
|
||||||
|
req.setRequestId("periodic-sync-local");
|
||||||
|
req.setBlockchainName(remoteBlock.blockchainName());
|
||||||
|
req.setBlockNumber(remoteBlock.blockNumber());
|
||||||
|
req.setPrevBlockHash(prevHash == null ? "" : prevHash);
|
||||||
|
req.setBlockBytesB64(remoteBlock.blockBytesB64());
|
||||||
|
|
||||||
|
Net_Response response = ADD_BLOCK_HANDLER.handle(req, null);
|
||||||
|
if (response.getStatus() >= 200 && response.getStatus() < 300) {
|
||||||
|
server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response ok =
|
||||||
|
(server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response) response;
|
||||||
|
return new LocalAddBlockApplyResult(true, "", "", ok.getServerLastGlobalHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_Exception_Response error = (Net_Exception_Response) response;
|
||||||
|
return new LocalAddBlockApplyResult(false, error.getCode(), error.getMessage(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ensureLocalChainExists(SyncServerEntry partner, String blockchainName) {
|
||||||
|
try {
|
||||||
|
if (STATE_DAO.getByBlockchainName(blockchainName) != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||||||
|
if (login == null || login.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (AppConfig.getInstance().getBoolean(CONFIG_IMPORT_PROFILE_FROM_PARTNER, false)) {
|
||||||
|
return importUserProfileFromPartner(partner, login);
|
||||||
|
}
|
||||||
|
SolanaUserPdaImportService.findOrImportByLogin(login);
|
||||||
|
return STATE_DAO.getByBlockchainName(blockchainName) != null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Periodic blockchain sync: failed to ensure local chain exists for blockchainName={} reason={}",
|
||||||
|
blockchainName, String.valueOf(e));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
BlockchainStateEntry state = buildStateFromProfile(profile, sizeLimit, now);
|
||||||
|
|
||||||
|
if (SOLANA_USERS_DAO.existsByLogin(profile.login())) {
|
||||||
|
STATE_DAO.insertIfMissing(state);
|
||||||
|
return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если пользователь уже успел существовать локально, но chain_state отсутствует,
|
||||||
|
// добиваем только state и не пытаемся пересоздать identity.
|
||||||
|
if (SOLANA_USERS_DAO.existsByLogin(profile.login())) {
|
||||||
|
STATE_DAO.insertIfMissing(state);
|
||||||
|
return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockchainStateEntry buildStateFromProfile(RemoteBlockchainSyncClient.RemoteSyncUserProfile profile,
|
||||||
|
long sizeLimit,
|
||||||
|
long nowMs) {
|
||||||
|
BlockchainStateEntry state = new BlockchainStateEntry();
|
||||||
|
state.setBlockchainName(profile.blockchainName());
|
||||||
|
state.setLogin(profile.login());
|
||||||
|
state.setBlockchainKey(profile.blockchainKey());
|
||||||
|
state.setSizeLimit(sizeLimit);
|
||||||
|
state.setFileSizeBytes(0L);
|
||||||
|
state.setLastBlockNumber(-1);
|
||||||
|
state.setLastBlockHash(null);
|
||||||
|
state.setUpdatedAtMs(nowMs);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex32(byte[] bytes32) {
|
||||||
|
byte[] bytes = bytes32;
|
||||||
|
if (bytes == null || bytes.length != 32) {
|
||||||
|
bytes = new byte[32];
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(64);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(Character.forDigit((b >>> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHex64(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return s.length() == 64 ? s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record LocalAddBlockApplyResult(
|
||||||
|
boolean ok,
|
||||||
|
String code,
|
||||||
|
String message,
|
||||||
|
String serverLastHash
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -2,199 +2,299 @@ package server.ws;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
import shine.log.BlockchainAdminNotifier;
|
import shine.log.BlockchainAdminNotifier;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ===============================================================
|
* ===============================================================
|
||||||
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
|
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
|
||||||
* blockchain файлов при старте сервера.
|
* файлов обычного AddBlock при старте сервера.
|
||||||
*
|
*
|
||||||
* Сценарий проблемы:
|
* Новая модель обычной записи:
|
||||||
* - при добавлении блока сначала пишется <name>.tmp_bch
|
* 1) собирается <name>.tmp_bch как полный кандидат на замену main;
|
||||||
* - потом коммитится БД (state.fileSizeBytes)
|
* 2) пишется sidecar <name>.write_check (blockNumber/blockHash);
|
||||||
* - потом tmp переименовывается поверх <name>.bch (атомарно, если возможно)
|
* 3) создаётся пустой marker <name>.write_pending;
|
||||||
|
* 4) выполняется SQL-транзакция;
|
||||||
|
* 5) после commit tmp атомарно ставится на место main;
|
||||||
|
* 6) marker и sidecar удаляются.
|
||||||
*
|
*
|
||||||
* Если сервер упал в середине, может остаться tmp:
|
* На старте:
|
||||||
* - tmp есть, а основной .bch остался старым
|
* - если найден marker, recovery добивает запись или чистит мусор;
|
||||||
* - tmp есть, а основной .bch уже удалили/заменить не успели
|
* - если marker нет, а tmp/check остались, это мусор и он удаляется;
|
||||||
* - tmp есть, а БД успела/не успела обновиться
|
* - legacy tmp-файлы без marker тоже считаются мусором.
|
||||||
*
|
*
|
||||||
* Этот класс при старте:
|
* Принцип:
|
||||||
* - ищет все *.tmp_bch в data/
|
* - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана;
|
||||||
* - сравнивает размеры:
|
* - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash);
|
||||||
* - tmp
|
* - если marker отсутствует, временные файлы не считаются валидными.
|
||||||
* - main (если есть)
|
|
||||||
* - state.fileSizeBytes (если есть)
|
|
||||||
*
|
|
||||||
* Правила:
|
|
||||||
*
|
|
||||||
* A) state есть:
|
|
||||||
* - если stateSize == mainSize => tmp удаляем
|
|
||||||
* - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile)
|
|
||||||
* - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору
|
|
||||||
*
|
|
||||||
* B) state НЕТ:
|
|
||||||
* - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции)
|
|
||||||
* - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера
|
|
||||||
*
|
|
||||||
* Логирование:
|
|
||||||
* - обо всех восстановленных/удалённых tmp пишем в лог
|
|
||||||
* - если tmp-файлов нет — тоже пишем в лог
|
|
||||||
* ===============================================================
|
* ===============================================================
|
||||||
*/
|
*/
|
||||||
public final class BlockchainTmpRecoveryOnStartup {
|
public final class BlockchainTmpRecoveryOnStartup {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
|
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
private BlockchainTmpRecoveryOnStartup() {}
|
private BlockchainTmpRecoveryOnStartup() {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Запуск восстановления.
|
|
||||||
* Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение.
|
|
||||||
*/
|
|
||||||
public static void runRecoveryOrThrow() {
|
public static void runRecoveryOrThrow() {
|
||||||
FileStoreUtil fs = FileStoreUtil.getInstance();
|
FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||||
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
|
BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
|
||||||
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
||||||
ensureDirExists(dataDir);
|
ensureDirExists(dataDir);
|
||||||
|
|
||||||
List<Path> tmpFiles = listTmpFiles(dataDir);
|
List<Path> markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
|
||||||
|
if (!markers.isEmpty()) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size());
|
||||||
|
} else {
|
||||||
|
log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено.");
|
||||||
|
}
|
||||||
|
|
||||||
if (tmpFiles.isEmpty()) {
|
for (Path marker : markers) {
|
||||||
log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется.");
|
recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupOrphanTempArtifacts(dataDir, fs);
|
||||||
|
log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void recoverSingleWriteMarkerOrThrow(Path markerPath,
|
||||||
|
FileStoreUtil fs,
|
||||||
|
BlockchainStateDAO stateDAO,
|
||||||
|
BlocksDAO blocksDAO) {
|
||||||
|
String markerFileName = markerPath.getFileName().toString();
|
||||||
|
String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
|
||||||
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
|
BlockchainAdminNotifier.critical(
|
||||||
|
"НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
throw new IllegalStateException("Bad write marker name: " + markerFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName);
|
||||||
|
Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName);
|
||||||
|
Path mainPath = fs.resolveBlockchainPath(blockchainName);
|
||||||
|
|
||||||
|
Map<String, String> meta = parseKeyValueFile(checkPath);
|
||||||
|
Integer expectedBlockNumber = parseInt(meta.get("blockNumber"));
|
||||||
|
String expectedBlockHashHex = normalizeHex(meta.get("blockHash"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName);
|
||||||
|
boolean mainExists = Files.exists(mainPath);
|
||||||
|
boolean tmpExists = Files.exists(tmpPath);
|
||||||
|
long mainSize = mainExists ? safeSize(mainPath) : -1L;
|
||||||
|
long tmpSize = tmpExists ? safeSize(tmpPath) : -1L;
|
||||||
|
|
||||||
|
if (st == null) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.",
|
||||||
|
blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size());
|
|
||||||
|
|
||||||
for (Path tmpPath : tmpFiles) {
|
|
||||||
String fileName = tmpPath.getFileName().toString();
|
|
||||||
String blockchainName = extractBlockchainNameFromTmp(fileName);
|
|
||||||
|
|
||||||
if (blockchainName == null || blockchainName.isBlank()) {
|
|
||||||
// странное имя — не трогаем автоматически, но это уже повод дернуть админа
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("Bad tmp file name: " + fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName));
|
|
||||||
|
|
||||||
long tmpSize = safeSize(tmpPath);
|
|
||||||
boolean mainExists = Files.exists(mainPath);
|
|
||||||
long mainSize = mainExists ? safeSize(mainPath) : -1L;
|
|
||||||
|
|
||||||
BlockchainStateEntry st = null;
|
|
||||||
try {
|
|
||||||
st = stateDAO.getByBlockchainName(blockchainName);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CASE B) state НЕТ
|
|
||||||
// ============================================================
|
|
||||||
if (st == null) {
|
|
||||||
|
|
||||||
if (!mainExists) {
|
|
||||||
// НЕТ state, НЕТ main, есть tmp => удаляем tmp
|
|
||||||
log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}",
|
|
||||||
blockchainName, tmpSize);
|
|
||||||
safeDelete(tmpPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// НЕТ state, но main есть и tmp есть => это уже подозрительно
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " +
|
|
||||||
"blockchainName=" + blockchainName +
|
|
||||||
", mainSize=" + mainSize +
|
|
||||||
", tmpSize=" + tmpSize +
|
|
||||||
". СЕРВЕР ОСТАНОВЛЕН. " +
|
|
||||||
"ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CASE A) state ЕСТЬ
|
|
||||||
// ============================================================
|
|
||||||
long stateSize = st.getFileSizeBytes();
|
long stateSize = st.getFileSizeBytes();
|
||||||
|
|
||||||
// 1) stateSize == mainSize => tmp мусор
|
if (expectedBlockNumber == null || expectedBlockHashHex == null) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.",
|
||||||
|
blockchainName);
|
||||||
|
|
||||||
if (mainExists && mainSize == stateSize) {
|
if (mainExists && mainSize == stateSize) {
|
||||||
log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
blockchainName, stateSize, mainSize, tmpSize);
|
return;
|
||||||
safeDelete(tmpPath);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
if (tmpExists && tmpSize == stateSize) {
|
||||||
// 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main
|
|
||||||
if (tmpSize == stateSize) {
|
|
||||||
log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
|
|
||||||
blockchainName, stateSize, mainSize, tmpSize);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE
|
|
||||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
// после move tmp должен исчезнуть сам (перемещён)
|
return;
|
||||||
log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}",
|
|
||||||
blockchainName, safeSize(mainPath));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " +
|
|
||||||
"blockchainName=" + blockchainName +
|
|
||||||
", stateSize=" + stateSize +
|
|
||||||
", mainSize=" + mainSize +
|
|
||||||
", tmpSize=" + tmpSize +
|
|
||||||
". СЕРВЕР ОСТАНОВЛЕН.",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e);
|
|
||||||
}
|
}
|
||||||
continue;
|
if (tmpExists && mainExists) {
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация
|
BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber);
|
||||||
|
if (block == null || block.getBlockHash() == null) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.",
|
||||||
|
blockchainName, expectedBlockNumber);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String actualHashHex = HEX.formatHex(block.getBlockHash());
|
||||||
|
if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.",
|
||||||
|
blockchainName, expectedBlockHashHex, actualHashHex);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если main уже совпадает со state — tmp/check/marker лишние.
|
||||||
|
if (mainExists && mainSize == stateSize) {
|
||||||
|
log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}",
|
||||||
|
blockchainName, stateSize, mainSize);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если tmp уже готов и совпадает со state — просто ставим его на место main.
|
||||||
|
if (tmpExists && tmpSize == stateSize) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}",
|
||||||
|
blockchainName, stateSize, tmpSize);
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если tmp нет, но DB уже закоммитила блок — пробуем восстановить tmp из main + block_bytes.
|
||||||
|
if (!tmpExists && mainExists) {
|
||||||
|
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
|
||||||
|
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.",
|
||||||
|
blockchainName);
|
||||||
|
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
|
||||||
|
fs.writeBlockchainTmp(blockchainName, rebuilt);
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes.
|
||||||
|
if (mainExists) {
|
||||||
|
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
|
||||||
|
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}",
|
||||||
|
blockchainName);
|
||||||
|
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
|
||||||
|
fs.writeBlockchainTmp(blockchainName, rebuilt);
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если ничего не совпало, это уже подозрительное состояние.
|
||||||
BlockchainAdminNotifier.critical(
|
BlockchainAdminNotifier.critical(
|
||||||
"ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " +
|
"НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName +
|
||||||
"blockchainName=" + blockchainName +
|
|
||||||
", stateSize=" + stateSize +
|
", stateSize=" + stateSize +
|
||||||
", mainExists=" + mainExists +
|
", mainExists=" + mainExists +
|
||||||
", mainSize=" + mainSize +
|
", mainSize=" + mainSize +
|
||||||
|
", tmpExists=" + tmpExists +
|
||||||
", tmpSize=" + tmpSize +
|
", tmpSize=" + tmpSize +
|
||||||
". СЕРВЕР ОСТАНОВЛЕН. " +
|
", expectedBlockNumber=" + expectedBlockNumber +
|
||||||
"ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.",
|
", expectedBlockHash=" + expectedBlockHashHex +
|
||||||
|
". Требуется ручная проверка.",
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
throw new IllegalStateException("Blockchain files mismatch for " + blockchainName);
|
throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
BlockchainAdminNotifier.critical(
|
||||||
|
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена.");
|
private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) {
|
||||||
|
List<Path> tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
|
||||||
|
List<Path> checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
|
||||||
|
|
||||||
|
if (tmpFiles.isEmpty() && checkFiles.isEmpty()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================================================================== */
|
log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size());
|
||||||
/* =============================== Helpers ============================== */
|
for (Path tmp : tmpFiles) {
|
||||||
/* ===================================================================== */
|
String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
|
||||||
|
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
safeDelete(tmp);
|
||||||
|
}
|
||||||
|
for (Path check : checkFiles) {
|
||||||
|
String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
|
||||||
|
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
safeDelete(check);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) {
|
||||||
|
safeDelete(markerPath);
|
||||||
|
safeDelete(checkPath);
|
||||||
|
safeDelete(tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) {
|
||||||
|
try {
|
||||||
|
byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0];
|
||||||
|
byte[] out = new byte[mainBytes.length + blockBytes.length];
|
||||||
|
System.arraycopy(mainBytes, 0, out, 0, mainBytes.length);
|
||||||
|
System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length);
|
||||||
|
return out;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseKeyValueFile(Path path) {
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
if (path == null || !Files.exists(path)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
|
||||||
|
if (line == null) continue;
|
||||||
|
String s = line.trim();
|
||||||
|
if (s.isEmpty() || s.startsWith("#")) continue;
|
||||||
|
int idx = s.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
String key = s.substring(0, idx).trim();
|
||||||
|
String value = s.substring(idx + 1).trim();
|
||||||
|
result.put(key, value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot read write_check file: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer parseInt(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHex(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureDirExists(Path dir) {
|
private static void ensureDirExists(Path dir) {
|
||||||
try {
|
try {
|
||||||
@ -206,31 +306,27 @@ public final class BlockchainTmpRecoveryOnStartup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Path> listTmpFiles(Path dataDir) {
|
private static List<Path> listFilesWithSuffix(Path dataDir, String suffix) {
|
||||||
List<Path> out = new ArrayList<>();
|
List<Path> out = new ArrayList<>();
|
||||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) {
|
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + suffix)) {
|
||||||
for (Path p : ds) {
|
for (Path p : ds) {
|
||||||
if (Files.isRegularFile(p)) out.add(p);
|
if (Files.isRegularFile(p)) {
|
||||||
|
out.add(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e);
|
throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static String extractBlockchainName(String fileName, String suffix) {
|
||||||
* Из "anya0001.tmp_bch" -> "anya0001"
|
if (fileName == null) return null;
|
||||||
*/
|
String s = fileName.trim();
|
||||||
private static String extractBlockchainNameFromTmp(String tmpFileName) {
|
if (!s.endsWith(suffix)) return null;
|
||||||
if (tmpFileName == null) return null;
|
String base = s.substring(0, s.length() - suffix.length());
|
||||||
if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null;
|
|
||||||
|
|
||||||
String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length());
|
|
||||||
|
|
||||||
// базовая защита: не допускаем слэши/.. даже если кто-то подложил файл
|
|
||||||
if (base.isBlank()) return null;
|
if (base.isBlank()) return null;
|
||||||
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
|
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import server.debug.DebugApiConfigurator;
|
import server.debug.DebugApiConfigurator;
|
||||||
|
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||||
|
import server.sync.PeriodicBlockchainSyncService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
@ -37,6 +39,16 @@ public final class WsServer {
|
|||||||
throw e; // останавливаем запуск
|
throw e; // останавливаем запуск
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 0.1) Восстановление цепочек, зависших на full resync
|
||||||
|
// ============================================================
|
||||||
|
try {
|
||||||
|
BlockchainResyncRecoveryOnStartup.runRecoveryOrThrow();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain resync-маркеров.", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 1) Настройки порта
|
// 1) Настройки порта
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -56,6 +68,11 @@ public final class WsServer {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
SyncServersBootstrapService.refreshFromSolanaOrLog();
|
SyncServersBootstrapService.refreshFromSolanaOrLog();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1.2) Плановый межсерверный sync блокчейнов
|
||||||
|
// ============================================================
|
||||||
|
PeriodicBlockchainSyncService.startOrLog();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 2) Запуск Jetty WS
|
// 2) Запуск Jetty WS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
40
TODO/2026-06-26_1800_корректное_завершение_за_30с.md
Normal file
40
TODO/2026-06-26_1800_корректное_завершение_за_30с.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Graceful shutdown на 30 секунд
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
Чтобы при `restart` или `stop` сервер получал небольшой запас времени на завершение опасных операций:
|
||||||
|
|
||||||
|
- текущий `AddBlock`;
|
||||||
|
- full resync цепочки;
|
||||||
|
- очистку marker-file;
|
||||||
|
- дописывание временных файлов.
|
||||||
|
|
||||||
|
Это должно уменьшить число случаев, когда приходится потом добирать хвост recovery-логикой.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
1. На стороне `systemd` задать `TimeoutStopSec=30s`.
|
||||||
|
2. В коде добавить корректную обработку shutdown-сигнала:
|
||||||
|
- перестать принимать новые блоки и sync-задачи;
|
||||||
|
- дождаться текущих операций;
|
||||||
|
- выйти в пределах таймаута.
|
||||||
|
3. При необходимости закрывать текущие ресурсы аккуратно:
|
||||||
|
- сетевые соединения;
|
||||||
|
- фоновые executor-ы;
|
||||||
|
- временные marker-файлы.
|
||||||
|
|
||||||
|
## Что уже есть
|
||||||
|
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта.
|
||||||
|
- `AddBlock` уже стал crash-safe через `tmp_bch` / `write_check` / `write_pending`.
|
||||||
|
|
||||||
|
## Откуда продолжать
|
||||||
|
|
||||||
|
- начать с `systemd`-юнита и базового shutdown-hook в сервере;
|
||||||
|
- затем проверить, что текущие операции реально завершаются в отведённые 30 секунд.
|
||||||
|
|
||||||
|
## Какие документы потом обновить
|
||||||
|
|
||||||
|
- `Dev_Docs/deploy/`;
|
||||||
|
- `Dev_Docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления.
|
||||||
|
|
||||||
41
TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md
Normal file
41
TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Постоянный server-to-server WS и DM sync
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации ещё нужен постоянный межсерверный канал:
|
||||||
|
|
||||||
|
- живое подключение к партнёру;
|
||||||
|
- push новых блоков;
|
||||||
|
- push DM;
|
||||||
|
- ACK на доставку;
|
||||||
|
- backoff/reconnect;
|
||||||
|
- стартовый backfill.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
1. Поднять постоянное WebSocket-соединение между партнёрскими серверами.
|
||||||
|
2. Сделать push новых блоков сразу после `AddBlock`.
|
||||||
|
3. Сделать push DM-блоков между серверами.
|
||||||
|
4. Добавить ACK и повторную отправку при сбое.
|
||||||
|
5. Ввести стартовый обмен курсорами и добор хвоста.
|
||||||
|
|
||||||
|
## Что уже есть
|
||||||
|
|
||||||
|
- `ListBlockchainHeads`;
|
||||||
|
- `GetBlockchainBlock`;
|
||||||
|
- `GetSyncUserProfile`;
|
||||||
|
- базовый periodic sync;
|
||||||
|
- базовый backfill хвоста;
|
||||||
|
- базовый full resync при divergence.
|
||||||
|
|
||||||
|
## Откуда продолжать
|
||||||
|
|
||||||
|
- от текущего `sync_servers` bootstrap и `PeriodicBlockchainSyncService`;
|
||||||
|
- дальше выделить отдельный межсерверный transport layer.
|
||||||
|
|
||||||
|
## Какие документы потом обновить
|
||||||
|
|
||||||
|
- `Dev_Docs/Blockchain/sync-between-servers.md`;
|
||||||
|
- `Dev_Docs/Personal_Messages/README.md`;
|
||||||
|
- `Dev_Docs/API/`.
|
||||||
|
|
||||||
30
TODO/2026-06-26_1810_подключение_устройств_по_qr.md
Normal file
30
TODO/2026-06-26_1810_подключение_устройств_по_qr.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Подключение других устройств по QR и типизированные сессии
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
QR-подключение других устройств сейчас есть как заготовка, но сценарий нужно довести до устойчивого состояния. Параллельно надо аккуратно оформить типизированные сессии homeserver-ов в PDA.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
1. Довести QR-сценарий до стабильного подключения нового устройства.
|
||||||
|
2. Нормально описать и хранить устройство как отдельную типизированную сессию.
|
||||||
|
3. Согласовать это с серверной и UI-логикой.
|
||||||
|
4. Проверить, что подключение работает одинаково на новом и повторном устройстве.
|
||||||
|
|
||||||
|
## Что уже есть
|
||||||
|
|
||||||
|
- в планах есть `сессионные homeserver-ы в PDA`;
|
||||||
|
- в планах есть `подключение других устройств через QR`;
|
||||||
|
- базовая заготовка уже существует, но сценарий считается нестабильным.
|
||||||
|
|
||||||
|
## Откуда продолжать
|
||||||
|
|
||||||
|
- от текущих документов в `Dev_Docs/Future_Features/medium/`;
|
||||||
|
- отдельно проверить, какие поля уже есть в PDA и UI.
|
||||||
|
|
||||||
|
## Какие документы потом обновить
|
||||||
|
|
||||||
|
- `Dev_Docs/Solana_Architecture/README.md`;
|
||||||
|
- `Dev_Docs/Future_Features/medium/2026-06-03_подключение_других_устройств_через_qr.md`;
|
||||||
|
- `Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md`.
|
||||||
|
|
||||||
29
TODO/2026-06-26_1815_esp32_файловое_хранилище.md
Normal file
29
TODO/2026-06-26_1815_esp32_файловое_хранилище.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# ESP32 как личное файловое хранилище
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
Планируется использовать ESP32 как личное файловое хранилище SHiNE для переписок и вложений.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
1. Продумать формат хранения файлов на устройстве.
|
||||||
|
2. Согласовать загрузку и чтение файлов между UI, сервером и устройством.
|
||||||
|
3. Проверить, как устройство показывает статусы и ошибки.
|
||||||
|
4. Свести это с существующим homeserver/UI-прототипом.
|
||||||
|
|
||||||
|
## Что уже есть
|
||||||
|
|
||||||
|
- в списке будущих фич уже есть отдельная задача по ESP32S3 file storage;
|
||||||
|
- для UI homeserver уже есть отдельная документация и скетч должны держаться синхронно.
|
||||||
|
|
||||||
|
## Откуда продолжать
|
||||||
|
|
||||||
|
- от `Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md`;
|
||||||
|
- от документации по ESP32 UI homeserver.
|
||||||
|
|
||||||
|
## Какие документы потом обновить
|
||||||
|
|
||||||
|
- `Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md`;
|
||||||
|
- `Dev_Docs/Future_Features/README.md`;
|
||||||
|
- документацию по ESP32 UI homeserver, если добавятся экраны или статусы.
|
||||||
|
|
||||||
@ -1,48 +1,44 @@
|
|||||||
# Будущие фичи
|
# TODO
|
||||||
|
|
||||||
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
|
Папка для короткого списка ближайших и среднесрочных задач, которые уже обсуждались и пока отложены.
|
||||||
|
|
||||||
## Горизонты планирования
|
|
||||||
|
|
||||||
- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
|
|
||||||
- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
|
|
||||||
- `far/` - дальнее будущее: идеи без понятного срока возврата.
|
|
||||||
|
|
||||||
Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
|
|
||||||
|
|
||||||
## Как использовать
|
## Как использовать
|
||||||
|
|
||||||
1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
|
- Один markdown-файл = одна задача.
|
||||||
2. В файле нужно фиксировать:
|
- В файле коротко фиксируем:
|
||||||
- зачем нужна фича;
|
- зачем это нужно;
|
||||||
- к какому сроку или горизонту она относится;
|
- что именно сделать;
|
||||||
- что нужно сделать;
|
- что уже есть в коде;
|
||||||
- какие вопросы нужно уточнить перед реализацией;
|
- откуда продолжать;
|
||||||
- что уже было сделано в коде, если фича частично реализована;
|
- какие документы потом надо обновить.
|
||||||
- что временно отключено или закомментировано, если применимо;
|
- Это не активная разработка. Тут только план и контекст.
|
||||||
- какие документы нужно обновить при возврате к задаче;
|
|
||||||
- с какого места продолжать разработку.
|
|
||||||
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
|
|
||||||
|
|
||||||
## Текущие планы
|
## Текущие задачи
|
||||||
|
|
||||||
### Ближайшие
|
- `2026-06-26_1800_корректное_завершение_за_30с.md` - дать сервису до 30 секунд на корректное завершение опасных операций перед рестартом.
|
||||||
|
- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - постоянный server-to-server WebSocket, push новых блоков и DM, ACK и backfill.
|
||||||
|
- `2026-06-26_1810_подключение_устройств_по_qr.md` - довести подключение других устройств по QR и перевести это в нормальные типизированные сессии.
|
||||||
|
- `2026-06-26_1815_esp32_файловое_хранилище.md` - использовать ESP32 как личное файловое хранилище для переписок и вложений.
|
||||||
|
|
||||||
|
## Перенесённые планы из `Dev_Docs/Future_Features/`
|
||||||
|
|
||||||
|
### near
|
||||||
|
|
||||||
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
||||||
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
|
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
|
||||||
|
|
||||||
### Среднесрочные
|
### medium
|
||||||
|
|
||||||
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
||||||
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
||||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
|
||||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||||
|
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||||
|
|
||||||
### DAO-запуск
|
### dao_запуск
|
||||||
|
|
||||||
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
|
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
|
||||||
|
|
||||||
### Дальнее будущее
|
### far
|
||||||
|
|
||||||
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
|
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.268
|
client.version=1.2.282
|
||||||
server.version=1.2.248
|
server.version=1.2.262
|
||||||
|
|||||||
@ -212,7 +212,7 @@ tasks.register('deployUIProduction', Exec) {
|
|||||||
|
|
||||||
tasks.register('deployServer', Exec) {
|
tasks.register('deployServer', Exec) {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Default deploy server: test2.shineup.me"
|
description = "Default deploy server: t.shineup.me"
|
||||||
dependsOn shadowJar
|
dependsOn shadowJar
|
||||||
workingDir = rootDir
|
workingDir = rootDir
|
||||||
environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath
|
environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath
|
||||||
@ -221,20 +221,20 @@ tasks.register('deployServer', Exec) {
|
|||||||
|
|
||||||
tasks.register('deployUI', Exec) {
|
tasks.register('deployUI', Exec) {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Default deploy UI: test2.shineup.me"
|
description = "Default deploy UI: t.shineup.me"
|
||||||
workingDir = rootDir
|
workingDir = rootDir
|
||||||
commandLine 'bash', file('deploy_shine-ui_test2.sh').absolutePath
|
commandLine 'bash', file('deploy_shine-ui_test2.sh').absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('deployServerTest2') {
|
tasks.register('deployServerTest2') {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Явный алиас основного test deploy server: test2.shineup.me"
|
description = "Явный алиас основного test deploy server: t.shineup.me"
|
||||||
dependsOn tasks.named('deployServer')
|
dependsOn tasks.named('deployServer')
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('deployUITest2') {
|
tasks.register('deployUITest2') {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Явный алиас основного test deploy UI: test2.shineup.me"
|
description = "Явный алиас основного test deploy UI: t.shineup.me"
|
||||||
dependsOn tasks.named('deployUI')
|
dependsOn tasks.named('deployUI')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,8 @@ export CLIENT_VERSION
|
|||||||
|
|
||||||
TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}"
|
TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}"
|
||||||
REMOTE_DIR="${REMOTE_UI_DIR}"
|
REMOTE_DIR="${REMOTE_UI_DIR}"
|
||||||
|
DEPLOY_SERVER_LOGIN="${DEPLOY_SERVER_LOGIN:-}"
|
||||||
|
DEPLOY_SERVER_ADDRESS="${DEPLOY_SERVER_ADDRESS:-}"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$TMP_DIR"
|
rm -rf "$TMP_DIR"
|
||||||
@ -42,6 +44,16 @@ echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION"
|
|||||||
echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)"
|
echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)"
|
||||||
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
||||||
|
|
||||||
|
DEPLOY_CONFIG_FILE="$TMP_DIR/js/deploy-config.js"
|
||||||
|
if [[ -f "$DEPLOY_CONFIG_FILE" ]]; then
|
||||||
|
if [[ -n "$DEPLOY_SERVER_LOGIN" ]]; then
|
||||||
|
perl -0pi -e "s/export const defaultServerLogin = '.*?';/export const defaultServerLogin = '$DEPLOY_SERVER_LOGIN';/s" "$DEPLOY_CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
if [[ -n "$DEPLOY_SERVER_ADDRESS" ]]; then
|
||||||
|
perl -0pi -e "s/export const defaultServerAddress = '.*?';/export const defaultServerAddress = '$DEPLOY_SERVER_ADDRESS';/s" "$DEPLOY_CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
INDEX_FILE="$TMP_DIR/index.html"
|
INDEX_FILE="$TMP_DIR/index.html"
|
||||||
if [[ ! -f "$INDEX_FILE" ]]; then
|
if [[ ! -f "$INDEX_FILE" ]]; then
|
||||||
echo "ERROR: index.html not found in staged UI: $INDEX_FILE" >&2
|
echo "ERROR: index.html not found in staged UI: $INDEX_FILE" >&2
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
#!/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:-test2.shineup.me}"
|
TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}"
|
||||||
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
|
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
|
||||||
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}"
|
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}"
|
||||||
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
|
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
|
||||||
@ -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,16 +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"
|
|
||||||
perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties"
|
|
||||||
|
|
||||||
cat >"$TMP_DIR/shine-server.service" <<EOF
|
cat >"$TMP_DIR/shine-server.service" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=SHiNE Server
|
Description=SHiNE Server
|
||||||
@ -59,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; \
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
|
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
|
||||||
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
|
TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}"
|
||||||
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
||||||
|
|
||||||
TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \
|
TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \
|
||||||
@ -12,10 +12,11 @@ REMOTE_HOST="$TARGET_HOST" \
|
|||||||
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
|
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
|
||||||
EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \
|
EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \
|
||||||
EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \
|
EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \
|
||||||
|
DEPLOY_SERVER_LOGIN="tshineupme" \
|
||||||
|
DEPLOY_SERVER_ADDRESS="$TARGET_DOMAIN" \
|
||||||
TARGET_URL="https://$TARGET_DOMAIN" \
|
TARGET_URL="https://$TARGET_DOMAIN" \
|
||||||
bash "$(dirname "$0")/deploy_shine-PWA.sh"
|
bash "$(dirname "$0")/deploy_shine-PWA.sh"
|
||||||
|
|
||||||
ssh "$TARGET_HOST" "sudo chmod o+x /home/player /home/player/SHiNE '$REMOTE_UI_DIR'; \
|
ssh "$TARGET_HOST" "sudo chmod o+x /home/player /home/player/SHiNE '$REMOTE_UI_DIR'; \
|
||||||
sudo find '$REMOTE_UI_DIR' -type d -exec chmod o+rx {} +; \
|
sudo find '$REMOTE_UI_DIR' -type d -exec chmod o+rx {} +; \
|
||||||
sudo find '$REMOTE_UI_DIR' -type f -exec chmod o+r {} +"
|
sudo find '$REMOTE_UI_DIR' -type f -exec chmod o+r {} +"
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
|
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
|
||||||
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
|
TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}"
|
||||||
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
||||||
REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}"
|
REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}"
|
||||||
|
|
||||||
@ -77,4 +77,3 @@ ssh "$TARGET_HOST" "set -euo pipefail; \
|
|||||||
sudo chown root:root '$REMOTE_CADDYFILE'; \
|
sudo chown root:root '$REMOTE_CADDYFILE'; \
|
||||||
sudo caddy validate --config '$REMOTE_CADDYFILE'; \
|
sudo caddy validate --config '$REMOTE_CADDYFILE'; \
|
||||||
sudo systemctl restart caddy"
|
sudo systemctl restart caddy"
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
getBalanceSol,
|
getBalanceSol,
|
||||||
getTopupSiteUrl,
|
getTopupSiteUrl,
|
||||||
} from '../services/solana-wallet-service.js';
|
} from '../services/solana-wallet-service.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
import {
|
import {
|
||||||
formatSolanaErrorDetails,
|
formatSolanaErrorDetails,
|
||||||
isUserAlreadyExistsSolanaError,
|
isUserAlreadyExistsSolanaError,
|
||||||
@ -185,7 +186,7 @@ export function render({ navigate }) {
|
|||||||
const raw = atob(publicKeyB64);
|
const raw = atob(publicKeyB64);
|
||||||
const bytes = new Uint8Array(raw.length);
|
const bytes = new Uint8Array(raw.length);
|
||||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||||
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
|
const { PublicKey } = await loadSolanaWeb3();
|
||||||
const address = new PublicKey(bytes).toBase58();
|
const address = new PublicKey(bytes).toBase58();
|
||||||
state.registrationPayment.walletAddress = address;
|
state.registrationPayment.walletAddress = address;
|
||||||
walletValue.value = address;
|
walletValue.value = address;
|
||||||
|
|||||||
@ -5,13 +5,14 @@ import {
|
|||||||
SOLANA_CLUSTER,
|
SOLANA_CLUSTER,
|
||||||
} from '../solana-programs.js';
|
} from '../solana-programs.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'solana-users-init-view', title: 'Solana Init (users)' };
|
export const pageMeta = { id: 'solana-users-init-view', title: 'Solana Init (users)' };
|
||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
function loadSolanaLib() {
|
function loadSolanaLib() {
|
||||||
if (!solanaLibPromise) {
|
if (!solanaLibPromise) {
|
||||||
solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
solanaLibPromise = loadSolanaWeb3();
|
||||||
}
|
}
|
||||||
return solanaLibPromise;
|
return solanaLibPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
getTopupSiteUrl,
|
getTopupSiteUrl,
|
||||||
requestAirdropSol,
|
requestAirdropSol,
|
||||||
} from '../services/solana-wallet-service.js';
|
} from '../services/solana-wallet-service.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
|
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
|
||||||
|
|
||||||
@ -20,7 +21,7 @@ async function clientWalletAddressFromBundle() {
|
|||||||
const raw = atob(keyBundle.clientPair.publicKeyB64);
|
const raw = atob(keyBundle.clientPair.publicKeyB64);
|
||||||
const bytes = new Uint8Array(raw.length);
|
const bytes = new Uint8Array(raw.length);
|
||||||
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
|
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
|
||||||
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
|
const { PublicKey } = await loadSolanaWeb3();
|
||||||
return new PublicKey(bytes).toBase58();
|
return new PublicKey(bytes).toBase58();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
SHINE_USERS_ECONOMY_CONFIG_SEED,
|
SHINE_USERS_ECONOMY_CONFIG_SEED,
|
||||||
SHINE_USERS_PROGRAM_ID,
|
SHINE_USERS_PROGRAM_ID,
|
||||||
} from '../solana-programs.js';
|
} from '../solana-programs.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
|
|
||||||
const MAGIC = 'SHiNE';
|
const MAGIC = 'SHiNE';
|
||||||
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
|
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
|
||||||
@ -30,7 +31,7 @@ const SESSION_TYPE_HOMESERVER = 100;
|
|||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
function loadSolanaLib() {
|
function loadSolanaLib() {
|
||||||
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3();
|
||||||
return solanaLibPromise;
|
return solanaLibPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
SHINE_USERS_PROGRAM_ID,
|
SHINE_USERS_PROGRAM_ID,
|
||||||
SHINE_LOGIN_GUARD_PROGRAM_ID,
|
SHINE_LOGIN_GUARD_PROGRAM_ID,
|
||||||
} from '../solana-programs.js';
|
} from '../solana-programs.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
|
|
||||||
const CLASSIFY_LOGIN_INSTRUCTION_TAG = 1;
|
const CLASSIFY_LOGIN_INSTRUCTION_TAG = 1;
|
||||||
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
|
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
|
||||||
@ -10,7 +11,7 @@ const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
|
|||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
function loadSolanaLib() {
|
function loadSolanaLib() {
|
||||||
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3();
|
||||||
return solanaLibPromise;
|
return solanaLibPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
|
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
|
||||||
import { loadEncryptedUserSecrets } from './key-vault.js';
|
import { loadEncryptedUserSecrets } from './key-vault.js';
|
||||||
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
|
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
|
|
||||||
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
|
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
|
||||||
const TOPUP_SITE_URL = '/devnet-topup-view';
|
const TOPUP_SITE_URL = '/devnet-topup-view';
|
||||||
@ -24,7 +25,7 @@ function normalizeEndpoint(url) {
|
|||||||
|
|
||||||
async function loadSolanaLib() {
|
async function loadSolanaLib() {
|
||||||
if (!solanaLibPromise) {
|
if (!solanaLibPromise) {
|
||||||
solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
solanaLibPromise = loadSolanaWeb3();
|
||||||
}
|
}
|
||||||
return solanaLibPromise;
|
return solanaLibPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
45
shine-UI/js/vendor/solana-web3-loader.js
vendored
Normal file
45
shine-UI/js/vendor/solana-web3-loader.js
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const SOLANA_WEB3_SCRIPT_SRC = './solana-web3.iife.min.js';
|
||||||
|
|
||||||
|
let solanaWeb3Promise = null;
|
||||||
|
|
||||||
|
function getExistingScript() {
|
||||||
|
return document.querySelector('script[data-shine-solana-web3="1"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScriptUrl() {
|
||||||
|
return new URL(SOLANA_WEB3_SCRIPT_SRC, import.meta.url).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSolanaWeb3() {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return Promise.reject(new Error('Solana Web3 loader доступен только в браузере'));
|
||||||
|
}
|
||||||
|
if (window.solanaWeb3) {
|
||||||
|
return Promise.resolve(window.solanaWeb3);
|
||||||
|
}
|
||||||
|
if (!solanaWeb3Promise) {
|
||||||
|
solanaWeb3Promise = new Promise((resolve, reject) => {
|
||||||
|
const existing = getExistingScript();
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(window.solanaWeb3), { once: true });
|
||||||
|
existing.addEventListener('error', () => reject(new Error('Не удалось загрузить локальный solana-web3.js')), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = resolveScriptUrl();
|
||||||
|
script.async = true;
|
||||||
|
script.dataset.shineSolanaWeb3 = '1';
|
||||||
|
script.onload = () => {
|
||||||
|
if (!window.solanaWeb3) {
|
||||||
|
reject(new Error('Локальный solana-web3.js загружен, но объект solanaWeb3 недоступен'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(window.solanaWeb3);
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error('Не удалось загрузить локальный solana-web3.js'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return solanaWeb3Promise;
|
||||||
|
}
|
||||||
20
shine-UI/js/vendor/solana-web3.iife.min.js
vendored
Normal file
20
shine-UI/js/vendor/solana-web3.iife.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -61,11 +61,11 @@
|
|||||||
<div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div>
|
<div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Адрес сервера (URL)</label>
|
<label>Арес сервера (URL) например shineup.me</label>
|
||||||
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
|
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Серверы синхронизации (sync_servers)</label>
|
<label>Логины серверов c которыми будет синхронизировать записи в блокчейн этот сервер (Например shineupme)</label>
|
||||||
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
|
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@ -86,11 +86,11 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Новые параметры сервера</h2>
|
<h2>Новые параметры сервера</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Новый адрес сервера (URL)</label>
|
<label>Арес сервера (URL) например shineup.me</label>
|
||||||
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
|
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Новые серверы синхронизации (sync_servers)</label>
|
<label>Логины серверов c которыми будет синхронизировать записи в блокчейн этот сервер (Например shineupme)</label>
|
||||||
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
|
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user