Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
585750007d | ||
|
|
6e80ac976a | ||
|
|
c70f18fcf4 | ||
|
|
d3e6aa2be2 | ||
|
|
b7a869c514 | ||
|
|
19362b950a | ||
|
|
0a4c31fb36 | ||
|
|
fef7694b48 | ||
|
|
60206e21df | ||
|
|
b9b77c66ce | ||
|
|
745a0e39d7 | ||
|
|
23bdadab56 | ||
|
|
47a4844ce8 | ||
|
|
8741be6cba | ||
|
|
4656a03ea9 | ||
|
|
5763eb828e | ||
|
|
6a5c20a165 | ||
|
|
fac166f186 | ||
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 | ||
|
|
8c4e404f23 | ||
|
|
e8a4713f6e | ||
|
|
b4b23afc10 | ||
|
|
8f32e82d14 | ||
|
|
cf391bdd80 | ||
|
|
7120d6a9db | ||
|
|
4d80ab639e | ||
|
|
95bbd2852e | ||
|
|
4b0a934e51 | ||
|
|
ee5d86003b | ||
|
|
bc11b60147 | ||
|
|
801a4697ea | ||
|
|
628a970e6f | ||
|
|
566ea942dc | ||
|
|
e047c2d54e | ||
|
|
3fdd3d22e2 | ||
|
|
08e0fa9206 | ||
|
|
ed9e6ce676 | ||
|
|
a7bf07130e | ||
|
|
7c31662698 | ||
|
|
15df06a0bc | ||
|
|
47b5f8db7b | ||
|
|
0e763987aa | ||
|
|
37e8ea27d0 | ||
|
|
67d63256c2 | ||
|
|
ee185cf20a | ||
|
|
3552e05e4c | ||
|
|
735bb55b78 | ||
|
|
60f8a50a97 | ||
|
|
ffd01814b0 | ||
|
|
c511910b8f | ||
|
|
5c38f8f0d8 | ||
|
|
d512442325 | ||
|
|
43f54c90d2 | ||
|
|
57a99b478a | ||
|
|
3f402fbde7 | ||
|
|
98140c1f71 | ||
|
|
f00de85e38 | ||
|
|
b686b12c6a | ||
|
|
e4b903b13c | ||
|
|
72f576a70e | ||
|
|
c4f2c7be63 | ||
|
|
75b75fd223 | ||
|
|
391b18a5ac | ||
|
|
8de0f8e87c | ||
|
|
adb0b76559 | ||
|
|
b6df25c02c | ||
|
|
620186db1c | ||
|
|
21fac8046e | ||
|
|
de7c35f587 | ||
|
|
a23c979045 | ||
|
|
13da7287b1 | ||
|
|
e8398df9fc | ||
|
|
8303682bd7 | ||
|
|
8125d1a124 | ||
|
|
110ff5e12e | ||
|
|
24afcba20a | ||
|
|
d5f41d12e8 | ||
|
|
d640dd66e2 | ||
|
|
05bd9f57d6 | ||
|
|
60ab476ec7 | ||
|
|
bcae285ab3 | ||
|
|
cd67176b41 | ||
|
|
98eba54621 | ||
|
|
c530627078 | ||
|
|
f961947e1e | ||
|
|
e9e6628b21 | ||
|
|
c7684d6fe1 | ||
|
|
7d475ab403 | ||
|
|
143adcbbe4 | ||
|
|
ca8b6a33ba | ||
|
|
2f73cdb0f9 | ||
|
|
d1e06d9ef1 | ||
|
|
0ed78e2202 | ||
|
|
64818c586b | ||
|
|
578f5cad4a | ||
|
|
d2ff277b16 | ||
|
|
df4f9b3f8e | ||
|
|
be7f53a9ae | ||
|
|
6ed91a105e | ||
|
|
0db3c3af5a | ||
|
|
23748504e6 | ||
|
|
f74fecddd8 | ||
|
|
618a30c2ab | ||
|
|
b5116474c7 | ||
|
|
91e7239866 | ||
|
|
1dddb5fb3c | ||
|
|
8ddf592ffc | ||
|
|
59a1117ed3 | ||
|
|
3bb6fa1e59 | ||
|
|
5af98ac911 | ||
|
|
f197196c21 | ||
|
|
ce5595bc16 | ||
|
|
1406111f22 | ||
|
|
bfdada6792 | ||
|
|
5326ad85d8 | ||
|
|
f9dd245481 | ||
|
|
20677a9090 | ||
|
|
d54cb7507a | ||
|
|
d6b313b582 | ||
|
|
f31cdb413b | ||
|
|
69faf55b2b | ||
|
|
4d9e42205f | ||
|
|
5df69d73c7 | ||
|
|
4704f8485b | ||
|
|
809c216a5c | ||
|
|
415a40e436 | ||
|
|
d233f1d73f | ||
|
|
9a7019a2ce | ||
|
|
07b1623c5e | ||
|
|
681437acb1 | ||
|
|
a183a586cb | ||
|
|
af62b51ef5 | ||
|
|
f640451257 | ||
|
|
a56252c361 | ||
|
|
38141ea5c7 | ||
|
|
d24d1c178b | ||
|
|
93673e5786 | ||
|
|
2f3b1571e5 | ||
|
|
65703f0fc4 | ||
|
|
4e674af4ed | ||
|
|
f9c2d7c63a | ||
|
|
968d85d738 | ||
|
|
d230b61c0b | ||
|
|
59047a8d0e | ||
|
|
81493ac4be | ||
|
|
cf33f9a3bd | ||
|
|
d2f65b169c | ||
|
|
fa1f7358b7 | ||
|
|
8208bb0b9d | ||
|
|
daa516babe | ||
|
|
6c2a9836eb | ||
|
|
bd3e6a56ca | ||
|
|
ea2ee1711c | ||
|
|
2890cdd457 | ||
|
|
e4dfb43b5e | ||
|
|
febdbc059e | ||
|
|
3926d561c0 | ||
|
|
d6bc883520 | ||
|
|
b2d20671cd | ||
|
|
a72e2e7014 | ||
|
|
cf6ca1d96b | ||
|
|
9d1e49949a | ||
|
|
ea3b34e11e | ||
|
|
09dc55db0e | ||
|
|
85133db483 | ||
|
|
6fc7d75e17 | ||
|
|
be321813fa | ||
|
|
d2c0ecdf62 | ||
|
|
266a74ef79 | ||
|
|
18d27c0146 | ||
|
|
8c4997ee2e | ||
|
|
127c561a41 |
@@ -13,6 +13,7 @@ build/
|
||||
.kotlin
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
@@ -102,10 +103,12 @@ ESP32/**/*.d
|
||||
ESP32/**/*.a
|
||||
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
server-backup/archive/**
|
||||
!server-backup/archive/.gitkeep
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
|
||||
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
.codex/
|
||||
.claude/
|
||||
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
||||
*.bak.png
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -1 +0,0 @@
|
||||
shine-server-server
|
||||
@@ -1,10 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" build-on-make="true" name="server:jar">
|
||||
<output-path>$PROJECT_DIR$/out/artifacts/server_jar</output-path>
|
||||
<root id="archive" name="server.jar">
|
||||
<element id="directory" name="META-INF">
|
||||
<element id="file-copy" path="$PROJECT_DIR$/META-INF/MANIFEST.MF" />
|
||||
</element>
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleHome" value="" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/shine-server-blockchain" />
|
||||
<option value="$PROJECT_DIR$/shine-server-config" />
|
||||
<option value="$PROJECT_DIR$/shine-server-crypto" />
|
||||
<option value="$PROJECT_DIR$/shine-server-db" />
|
||||
<option value="$PROJECT_DIR$/shine-server-geo" />
|
||||
<option value="$PROJECT_DIR$/shine-server-log" />
|
||||
<option value="$PROJECT_DIR$/shine-server-net-protocol" />
|
||||
<option value="$PROJECT_DIR$/shine-server-net-server" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17 (2)" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -14,14 +14,15 @@
|
||||
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
||||
- точка входа `shine-UI/server-ui.html`;
|
||||
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
||||
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
||||
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
|
||||
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
||||
|
||||
## Сервис агента-кодера
|
||||
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
||||
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
|
||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
|
||||
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`.
|
||||
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`.
|
||||
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
|
||||
|
||||
## ESP32 UI homeserver
|
||||
@@ -36,70 +37,67 @@
|
||||
- Модуль логически связан с SHiNE, но не должен автоматически подключаться к сборке или деплою основного сервера без отдельного решения.
|
||||
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
|
||||
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
|
||||
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля с `deployServer`/`deployUI` основного проекта.
|
||||
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля со скриптами основного server/UI deploy из `deploy/scripts/`.
|
||||
- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
|
||||
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
|
||||
- `docs/Инициализация_Solana_регистрации/README.md`
|
||||
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
|
||||
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
||||
- `Dev_Docs/Solana_Architecture/README.md`
|
||||
- `docs/Solana_Architecture/README.md`
|
||||
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
||||
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
||||
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
||||
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||
- `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md`
|
||||
|
||||
## Документация блокчейна
|
||||
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
|
||||
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
||||
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
||||
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
|
||||
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
|
||||
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `docs/Blockchain/`.
|
||||
- Дополнительно обязательно вести `docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
|
||||
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
|
||||
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
|
||||
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
|
||||
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
|
||||
|
||||
## Документация личных сообщений (DM)
|
||||
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`.
|
||||
- Точный байтовый формат DM находится в `Dev_Docs/Personal_Messages/Формат_DM_v1.md`.
|
||||
- Актуальная документация по логике личных сообщений находится в `docs/Personal_Messages/Протокол_DM_v1.md`.
|
||||
- Точный байтовый формат DM находится в `docs/Personal_Messages/Формат_DM_v1.md`.
|
||||
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять оба документа:
|
||||
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
|
||||
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`
|
||||
- `docs/Personal_Messages/Формат_DM_v1.md`
|
||||
- Логика личных сообщений в коде должна всегда соответствовать этим документам.
|
||||
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
|
||||
|
||||
## Документация API сервера
|
||||
- Актуальная документация по публичному JSON/WebSocket API сервера находится в `Dev_Docs/API/`.
|
||||
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `Dev_Docs/API/`.
|
||||
- Актуальная документация по публичному JSON/WebSocket API сервера находится в `docs/API/`.
|
||||
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `docs/API/`.
|
||||
- Перед изменением самого серверного API обязательно явно предупредить пользователя, какие операции, поля запросов/ответов или коды ошибок будут изменены, и запросить отдельное подтверждение.
|
||||
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
|
||||
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
|
||||
- Если добавляется новая операция `op`, нужно обновить общий список операций в `docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
|
||||
|
||||
## Документация Figma
|
||||
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`.
|
||||
- Точка входа: `Dev_Docs/Figma/README.md`.
|
||||
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
|
||||
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `docs/Figma/`.
|
||||
- Точка входа: `docs/Figma/README.md`.
|
||||
- Подробный рабочий регламент: `docs/Figma/TRANSFER_UI_SCREENS.md`.
|
||||
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
||||
|
||||
## Версионирование
|
||||
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
||||
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
|
||||
- `client.version` — версия клиентского UI.
|
||||
- `server.version` — версия серверной части.
|
||||
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
||||
- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется.
|
||||
- Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`.
|
||||
- Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория.
|
||||
|
||||
## Deploy
|
||||
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
||||
- Production-хост SHiNE: `player@shineup.me` (`178.208.64.62`).
|
||||
- Основной test-хост SHiNE: `player@193.8.215.70` (`t.shineup.me`).
|
||||
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
|
||||
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
||||
- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`.
|
||||
- Подробные правила для агента по деплою находятся в `deploy/AGENTS.md`; перед любым deploy читать этот файл.
|
||||
- Production-серверы SHiNE: `shineup.me` и `server2.shineup.me`.
|
||||
- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`.
|
||||
- В deploy-документах и скриптах использовать домены, а не IP.
|
||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
|
||||
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `t.shineup.me`.
|
||||
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
|
||||
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
|
||||
- Production server deploy: `./gradlew deployServerProduction`.
|
||||
- Production UI deploy: `./gradlew deployUIProduction`.
|
||||
- Резервный test deploy на `test.shineup.me`: `./gradlew deployServerTest` и `./gradlew deployUITest`, но пока их не использовать без отдельной причины.
|
||||
- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя.
|
||||
- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`.
|
||||
- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически.
|
||||
- Deploy выполнять shell-скриптами из `deploy/scripts/`; Gradle deploy-задачи не использовать.
|
||||
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
|
||||
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
|
||||
- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
|
||||
@@ -125,26 +123,13 @@
|
||||
- `unknown_error`
|
||||
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
|
||||
|
||||
## Недопроверенные фичи (обязательно)
|
||||
- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`.
|
||||
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
|
||||
- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`.
|
||||
- Имена новых файлов и краткие описания фич по возможности писать на русском языке.
|
||||
- Внутри файла обязательно указывать:
|
||||
- краткое описание фичи;
|
||||
- что именно проверять;
|
||||
- ожидаемый результат;
|
||||
- статус (например: `pending`, `in_progress`, `done`).
|
||||
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
||||
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
||||
|
||||
## Будущие фичи / TODO
|
||||
- Папка для задач, сознательно отложенных на будущее: `TODO/`.
|
||||
- Точка входа по планам: `TODO/README.md`.
|
||||
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам.
|
||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок.
|
||||
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
||||
- Старую папку `Dev_Docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
|
||||
- Старую папку `docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
|
||||
- Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать:
|
||||
- какие файлы и участки отключены;
|
||||
- что осталось в коде как заготовка;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@shine-UI/AGENTS.md
|
||||
|
||||
## Справка по подпроектам
|
||||
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- При работе с локальным агентом-кодером — читать внешние файлы `../SHiNE-agent-bot-coder/AGENTS.md` и `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
|
||||
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
||||
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Правила коммитов, merge и версионирования
|
||||
|
||||
Этот файл является единым источником правил для:
|
||||
- коммитов;
|
||||
- merge в `main`;
|
||||
- изменения версий в `VERSION.properties`;
|
||||
- `git push` из этого репозитория.
|
||||
|
||||
## Язык
|
||||
|
||||
- Пояснения к коммитам, PR и merge-запросам писать на русском языке.
|
||||
|
||||
## Где хранится версия
|
||||
|
||||
- Единый файл версий проекта: `VERSION.properties` в корне репозитория.
|
||||
- Основные поля:
|
||||
- `client.version` — версия клиентского UI.
|
||||
- `server.version` — версия серверной части.
|
||||
|
||||
## Базовое правило для обычных коммитов
|
||||
|
||||
- Перед каждым новым коммитом обязательно обновлять версии в `VERSION.properties`.
|
||||
- Если менялся только UI, увеличивать только `client.version`.
|
||||
- Если менялся только сервер, увеличивать только `server.version`.
|
||||
- Если менялись и UI, и сервер, увеличивать обе версии.
|
||||
- Для обычных коммитов вне `main` использовать стандартный patch-инкремент: `+1` к последнему числовому сегменту.
|
||||
- Пример: `1.2.346` → `1.2.347`.
|
||||
|
||||
## Правило для `main`
|
||||
|
||||
- Ветка `main` предназначена только для стабильных версий.
|
||||
- По умолчанию в `main` нужно не коммитить напрямую, а мержить готовые изменения из рабочей ветки.
|
||||
- Если пользователь просит сделать прямой коммит в `main`, нужно отдельно и явно предупредить, что это обход обычного стабильного процесса, и обязательно переспросить подтверждение.
|
||||
|
||||
## Версионирование при merge или прямом коммите в `main`
|
||||
|
||||
- Для попадания изменений в `main` действует отдельная схема инкремента.
|
||||
- Нужно увеличивать вторую цифру версии и обнулять третью.
|
||||
- Пример: `1.2.346` → `1.3.0`.
|
||||
- Если в наборе изменений менялся только UI, обновлять только `client.version`.
|
||||
- Если в наборе изменений менялся только сервер, обновлять только `server.version`.
|
||||
- Если соответствующая часть не менялась, её версию не трогать.
|
||||
|
||||
## Правила для git commit и git push
|
||||
|
||||
- Обычные коммиты делать стандартным `git commit`; токен для локального коммита не нужен и не используется.
|
||||
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Production-серверы SHiNE
|
||||
|
||||
## Короткий ответ
|
||||
|
||||
По текущим данным репозитория у SHiNE описаны **два production-контура**:
|
||||
|
||||
- `player@shineup.me`
|
||||
- домен `shineup.me`
|
||||
- IP `178.208.64.62`
|
||||
|
||||
и
|
||||
|
||||
- `player@193.8.215.70`
|
||||
- домен `t.shineup.me`
|
||||
- IP `193.8.215.70`
|
||||
|
||||
Отдельно резервный хост `test.shineup.me` сейчас помечен как временно неработающий.
|
||||
|
||||
## 1. Основной production-хост
|
||||
|
||||
- SSH: `player@shineup.me`
|
||||
- домен: `shineup.me`
|
||||
- IP: `178.208.64.62`
|
||||
- пользователь: `player`
|
||||
- базовый путь: `/home/player`
|
||||
|
||||
Основные каталоги:
|
||||
|
||||
- проект SHiNE: `/home/player/SHiNE`
|
||||
- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||
- UI: `/home/player/SHiNE/shine-ui`
|
||||
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||
|
||||
Сервисы:
|
||||
|
||||
- `shine-server.service`
|
||||
- `caddy.service`
|
||||
|
||||
Caddy:
|
||||
|
||||
- активный конфиг: `/etc/caddy/Caddyfile`
|
||||
- UI root: `/home/player/SHiNE/shine-ui`
|
||||
- `/ws` проксируется на `127.0.0.1:7070`
|
||||
|
||||
Deploy:
|
||||
|
||||
- `./gradlew deployServerProduction`
|
||||
- `./gradlew deployUIProduction`
|
||||
|
||||
Правило:
|
||||
|
||||
- любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя.
|
||||
|
||||
## 2. Второй production-сервер
|
||||
|
||||
- SSH: `player@193.8.215.70`
|
||||
- домен: `t.shineup.me`
|
||||
- IP: `193.8.215.70`
|
||||
- пользователь: `player`
|
||||
- базовый путь: `/home/player`
|
||||
|
||||
Роль:
|
||||
|
||||
- второй production-контур SHiNE;
|
||||
- использовать как production-сервер, несмотря на исторические имена deploy-задач
|
||||
`deployServerTest2` / `deployUITest2`.
|
||||
|
||||
Основные каталоги:
|
||||
|
||||
- проект SHiNE: `/home/player/SHiNE`
|
||||
- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||
- UI: `/home/player/SHiNE/shine-ui`
|
||||
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||
|
||||
## 3. Связанные публичные production-публикации на том же хосте
|
||||
|
||||
На этом же production-хосте есть отдельная публикация для `shine_payments`:
|
||||
|
||||
- каталог: `/home/player/sites/test-solana-tickets.shineup.me`
|
||||
- домены:
|
||||
- `https://test-solana-tickets.shineup.me`
|
||||
- `https://test-solana-tickets.shiningpeople.ru`
|
||||
|
||||
Это не второй production-хост SHiNE, а отдельный сайт на том же сервере.
|
||||
|
||||
## 4. Какие серверы не считать production
|
||||
|
||||
Не production:
|
||||
|
||||
- `test.shineup.me` (`93.170.12.154`) — резервный хост, временно не работает
|
||||
- `t1.shineup.me`
|
||||
- `t2.shineup.me`
|
||||
- `t3.shineup.me`
|
||||
- `t4.shineup.me`
|
||||
@@ -1,41 +0,0 @@
|
||||
# Deploy
|
||||
|
||||
Подробности о том, где что задеплоено в SHiNE, нужно искать в папке `Deploy/`.
|
||||
|
||||
Эта папка служит краткой картой окружений:
|
||||
|
||||
- [TEST_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/TEST_SERVERS.md) — тестовые серверы и стенды;
|
||||
- [PRODUCTION_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/PRODUCTION_SERVERS.md) — production-контур и связанные публичные публикации.
|
||||
|
||||
Ниже краткая сводка.
|
||||
|
||||
## Основные публичные контуры
|
||||
|
||||
- Production SHiNE:
|
||||
- `player@shineup.me`
|
||||
- домен `shineup.me`
|
||||
- IP `178.208.64.62`
|
||||
- Второй production SHiNE:
|
||||
- `player@193.8.215.70`
|
||||
- домен `t.shineup.me`
|
||||
- IP `193.8.215.70`
|
||||
- Временно неработающий резервный test:
|
||||
- `player@93.170.12.154`
|
||||
- домен `test.shineup.me`
|
||||
- IP `93.170.12.154`
|
||||
|
||||
## Отдельный quad-devnet стенд
|
||||
|
||||
На отдельном VPS `178.208.90.249` подняты 4 независимых test/devnet-инстанса:
|
||||
|
||||
- `t1.shineup.me`
|
||||
- `t2.shineup.me`
|
||||
- `t3.shineup.me`
|
||||
- `t4.shineup.me`
|
||||
|
||||
## Важно
|
||||
|
||||
- Production-контура SHiNE сейчас два: `shineup.me` и `t.shineup.me`.
|
||||
- `test.shineup.me` сейчас помечен как временно неработающий резервный test-хост.
|
||||
- `t1..t4.shineup.me` — это отдельные тестовые/devnet-контуры, не production.
|
||||
- Любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя.
|
||||
@@ -1,150 +0,0 @@
|
||||
# Тестовые серверы SHiNE
|
||||
|
||||
Этот файл описывает тестовые контуры, которые сейчас фигурируют в проекте.
|
||||
|
||||
## 1. Исторический `test2`, теперь второй production-сервер
|
||||
|
||||
- SSH: `player@193.8.215.70`
|
||||
- Домен: `t.shineup.me`
|
||||
- IP: `193.8.215.70`
|
||||
- Назначение: второй production-контур SHiNE
|
||||
|
||||
Структура:
|
||||
|
||||
- каталог SHiNE: `/home/player/SHiNE`
|
||||
- сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||
- UI: `/home/player/SHiNE/shine-ui`
|
||||
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||
|
||||
Сервисы:
|
||||
|
||||
- `shine-server.service`
|
||||
- `caddy.service`
|
||||
|
||||
Deploy:
|
||||
|
||||
- `./gradlew deployServer`
|
||||
- `./gradlew deployServerTest2`
|
||||
- `./gradlew deployUI`
|
||||
- `./gradlew deployUITest2`
|
||||
|
||||
Примечания:
|
||||
|
||||
- этот хост больше не считать test-контуром;
|
||||
- исторические имена deploy-задач `deployServerTest2` / `deployUITest2` сохранены, но сам хост считать production;
|
||||
- при описании окружений перечислять его как второй production-сервер.
|
||||
|
||||
## 2. Резервный test-сервер, временно неработающий
|
||||
|
||||
- SSH: `player@93.170.12.154`
|
||||
- Домен: `test.shineup.me`
|
||||
- IP: `93.170.12.154`
|
||||
- Назначение: резервный test
|
||||
|
||||
Структура по проектным докам:
|
||||
|
||||
- каталог SHiNE: `/home/player/SHiNE`
|
||||
- сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||
- UI: `/home/player/SHiNE/shine-ui`
|
||||
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||
|
||||
Deploy:
|
||||
|
||||
- `./gradlew deployServerTest`
|
||||
- `./gradlew deployUITest`
|
||||
|
||||
Примечания:
|
||||
|
||||
- этот хост резервный;
|
||||
- сейчас помечен как временно неработающий;
|
||||
- использовать его без отдельной причины не нужно.
|
||||
|
||||
## 3. Отдельный quad-devnet стенд `t1..t4`
|
||||
|
||||
- VPS: `178.208.90.249`
|
||||
- пользователь: `player`
|
||||
- назначение: 4 независимых SHiNE-инстанса на Solana `devnet`
|
||||
|
||||
Домены и логины:
|
||||
|
||||
- `server_t1` -> `https://t1.shineup.me`
|
||||
- `server_t2` -> `https://t2.shineup.me`
|
||||
- `server_t3` -> `https://t3.shineup.me`
|
||||
- `server_t4` -> `https://t4.shineup.me`
|
||||
|
||||
Каталоги:
|
||||
|
||||
- `/home/player/t1/server`
|
||||
- `/home/player/t1/UI`
|
||||
- `/home/player/t2/server`
|
||||
- `/home/player/t2/UI`
|
||||
- `/home/player/t3/server`
|
||||
- `/home/player/t3/UI`
|
||||
- `/home/player/t4/server`
|
||||
- `/home/player/t4/UI`
|
||||
|
||||
Подробная памятка на самом VPS:
|
||||
|
||||
- `/home/player/Agents.md`
|
||||
|
||||
Порты и systemd:
|
||||
|
||||
- `t1` -> `7101` -> `shine-t1.service`
|
||||
- `t2` -> `7102` -> `shine-t2.service`
|
||||
- `t3` -> `7103` -> `shine-t3.service`
|
||||
- `t4` -> `7104` -> `shine-t4.service`
|
||||
|
||||
Что важно по конфигу каждого инстанса:
|
||||
|
||||
- отдельный `/home/player/tX/server/application.properties`
|
||||
- `server.port=710X`
|
||||
- `server.SHiNE.login=server_tX`
|
||||
- `db.path=data/shine.sqlite`
|
||||
- `solana.cluster=devnet`
|
||||
- `solana.rpcUrl=https://api.devnet.solana.com`
|
||||
- `server.ui.indexPath=/home/player/tX/UI/index.html`
|
||||
- `server.info.url=https://tX.shineup.me`
|
||||
|
||||
UI каждого инстанса:
|
||||
|
||||
- живёт в отдельной копии `shine-UI`;
|
||||
- использует свой `js/deploy-config.js`;
|
||||
- по умолчанию смотрит именно на свой `tX.shineup.me`.
|
||||
|
||||
Caddy на стенде:
|
||||
|
||||
- конфиг: `/etc/caddy/Caddyfile`
|
||||
- статика: `/home/player/tX/UI`
|
||||
- `/ws` проксируется на `127.0.0.1:710X`
|
||||
|
||||
Operational-нюанс:
|
||||
|
||||
- при одновременных рестартах возможны `HTTP 429` от `api.devnet.solana.com`;
|
||||
- поэтому сервисы `shine-t1..shine-t4` лучше перезапускать по одному, с паузой.
|
||||
|
||||
## 4. Что проверять первым делом
|
||||
|
||||
Для любого test-контура полезны такие быстрые проверки:
|
||||
|
||||
```bash
|
||||
curl -I https://t.shineup.me
|
||||
curl -I https://test.shineup.me
|
||||
curl -I https://t1.shineup.me
|
||||
curl -I https://t2.shineup.me
|
||||
curl -I https://t3.shineup.me
|
||||
curl -I https://t4.shineup.me
|
||||
```
|
||||
|
||||
Для quad-devnet VPS:
|
||||
|
||||
```bash
|
||||
sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4
|
||||
```
|
||||
|
||||
Для основного/резервного test:
|
||||
|
||||
```bash
|
||||
sudo systemctl --no-pager --full status shine-server caddy
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Задание для Айдара: навести порядок в инструкциях агентов SHiNE
|
||||
|
||||
## Кратко
|
||||
Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками.
|
||||
|
||||
## Зачем это нужно
|
||||
Сейчас проект состоит из нескольких связанных, но разных частей:
|
||||
|
||||
- основной сервер `SHiNE-server/`;
|
||||
- UI `shine-UI/`;
|
||||
- Solana/Anchor-модуль `shine-solana/shine/`;
|
||||
- Telegram-агент-кодер `SHiNE-agent-bot-coder/`;
|
||||
- TURN-сервер;
|
||||
- документация `Dev_Docs/`;
|
||||
- отдельные рабочие папки игроков `Players/`.
|
||||
|
||||
Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций.
|
||||
|
||||
## Что предлагается сделать
|
||||
1. Утвердить корневой `AGENTS.md` как главный набор правил проекта.
|
||||
2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`.
|
||||
3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`.
|
||||
4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`.
|
||||
5. Явно закрепить режим игроков:
|
||||
- игроки могут задавать вопросы, просить анализ, идеи и ТЗ;
|
||||
- игроки не меняют код проекта напрямую;
|
||||
- материалы игроков сохраняются только в `Players/<username>/`.
|
||||
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
|
||||
7. Добавить простой процесс согласования изменений инструкций:
|
||||
- Дима или другой участник готовит предложение;
|
||||
- Айдар получает уведомление/заявку;
|
||||
- Айдар отвечает: одобрить, отклонить или попросить доработать;
|
||||
- только после одобрения агент вносит изменения в проектные инструкции.
|
||||
|
||||
## Предлагаемая логика уведомления Айдару
|
||||
Минимальный вариант без сложной разработки:
|
||||
|
||||
1. Агент готовит текст заявки.
|
||||
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
|
||||
3. В заявке явно указаны варианты ответа:
|
||||
- `одобрить`;
|
||||
- `отклонить`;
|
||||
- `доработать: ...`.
|
||||
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
|
||||
|
||||
Более удобный вариант на будущее:
|
||||
|
||||
- добавить в Telegram-бота команду или сценарий согласования задач, например:
|
||||
- `/approve <id>`;
|
||||
- `/reject <id> причина`;
|
||||
- `/revise <id> комментарий`.
|
||||
|
||||
Но для начала достаточно простого текстового согласования через Telegram.
|
||||
|
||||
## Что нужно от Айдара
|
||||
Подтвердить, что такой порядок подходит:
|
||||
|
||||
1. Корневой `AGENTS.md` остается главным правилом проекта.
|
||||
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
|
||||
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
|
||||
4. Изменения инструкций выполняются только после явного одобрения Айдара.
|
||||
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
|
||||
|
||||
## Ожидаемый результат
|
||||
После одобрения:
|
||||
|
||||
- агенты будут стабильнее понимать границы проекта;
|
||||
- снизится риск случайных изменений не в той части системы;
|
||||
- появится понятный порядок согласования задач от игроков;
|
||||
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
TELEGRAM_BOT_TOKEN=replace_me
|
||||
OPENAI_API_KEY=replace_me
|
||||
ALLOWED_TELEGRAM_USERNAME=AidarKC
|
||||
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
|
||||
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
|
||||
BOT_USERNAME=aidar_su_bot
|
||||
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
|
||||
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
|
||||
OPENAI_TTS_MODEL=gpt-4o-mini-tts
|
||||
OPENAI_TTS_VOICE=alloy
|
||||
OPENAI_TTS_RESPONSE_FORMAT=opus
|
||||
OPENAI_TTS_TIMEOUT_SECONDS=180
|
||||
OPENAI_TTS_CHUNK_CHARS=3500
|
||||
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
|
||||
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
|
||||
CODEX_TIMEOUT_SECONDS=900
|
||||
MAX_RETRIES=3
|
||||
DATA_DIR=./data
|
||||
@@ -1,5 +0,0 @@
|
||||
.env
|
||||
data/
|
||||
logs/
|
||||
run/
|
||||
__pycache__/
|
||||
@@ -1,81 +0,0 @@
|
||||
# AGENT.md для SHiNE-agent-bot-coder
|
||||
|
||||
Ты запущен как обработчик входящего Telegram-сообщения от пользователя.
|
||||
|
||||
## Контекст
|
||||
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с этим проектом.
|
||||
- Сервис принимает входящие сообщения от пользователя Telegram, сохраняет историю, ставит задачи в очередь и последовательно запускает Codex CLI в рабочем проекте.
|
||||
- Текстовые сообщения обрабатываются напрямую, voice/audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача.
|
||||
- История диалога хранится в JSONL-файле, путь передаётся в промпте.
|
||||
- Сообщение может быть текстом или результатом распознавания голосового.
|
||||
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
|
||||
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
|
||||
- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды.
|
||||
- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
|
||||
|
||||
## Авторитет команд и история
|
||||
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
||||
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
|
||||
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
|
||||
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
|
||||
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
|
||||
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
|
||||
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
|
||||
|
||||
## Очередь и состояние
|
||||
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
||||
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
||||
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
||||
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
||||
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
|
||||
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
||||
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
||||
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
|
||||
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
||||
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
|
||||
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
|
||||
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
|
||||
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
|
||||
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
|
||||
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
|
||||
|
||||
## Правила голосовой версии ответа
|
||||
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
|
||||
- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS.
|
||||
- В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух.
|
||||
- Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое.
|
||||
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
|
||||
|
||||
## Планы и отложенные фичи
|
||||
- Планы проекта по отложенным фичам хранятся в `TODO/`.
|
||||
- Внутри есть три горизонта:
|
||||
- `near/` - ближайшие планы, обычно сегодня/завтра;
|
||||
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
|
||||
- `far/` - дальнее будущее без понятного срока.
|
||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
|
||||
- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя.
|
||||
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
|
||||
|
||||
## Центр задач и предложений
|
||||
- Сервис хранит простые задачи и предложения в `data/task_center/items.json`.
|
||||
- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы».
|
||||
- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...».
|
||||
- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`.
|
||||
- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`.
|
||||
- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения.
|
||||
|
||||
## Локальный запуск и systemd
|
||||
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
|
||||
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
|
||||
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
|
||||
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
|
||||
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
|
||||
- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`.
|
||||
|
||||
## Правила ответа
|
||||
- Пиши содержательно и коротко.
|
||||
- Не упоминай внутренние служебные детали, файловую систему и технические логи.
|
||||
- Если запрос требует действий с кодом/проектом, выполняй их в рабочей директории.
|
||||
- Если для ответа данных недостаточно, задай ровно один уточняющий вопрос.
|
||||
- Если была ошибка предыдущего запуска, в промпте будет пометка retry — учти это и продолжи с учётом текущего состояния проекта.
|
||||
@@ -1,25 +0,0 @@
|
||||
# AGENTS
|
||||
|
||||
## Назначение
|
||||
- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`.
|
||||
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE.
|
||||
- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`.
|
||||
|
||||
## Связанные инструкции
|
||||
- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`.
|
||||
- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений.
|
||||
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
|
||||
|
||||
## Планы и задачи
|
||||
- Отложенные задачи проекта лежат в `../TODO/`.
|
||||
- Точка входа по планам: `../TODO/README.md`.
|
||||
- Горизонты планов:
|
||||
- `near/` - ближайшие планы;
|
||||
- `medium/` - среднесрочные планы;
|
||||
- `far/` - дальнее будущее.
|
||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
|
||||
- Не начинать реализацию задач из `TODO` без явной команды пользователя.
|
||||
|
||||
## Проверка после изменений
|
||||
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
|
||||
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
|
||||
@@ -1,2 +0,0 @@
|
||||
@AGENTS.md
|
||||
@AGENT.md
|
||||
@@ -1,26 +0,0 @@
|
||||
# Промпты для режима игроков (на согласование)
|
||||
|
||||
## 1) Базовый служебный промпт (добавка к задаче игрока)
|
||||
|
||||
```text
|
||||
Режим игрока (обязательно):
|
||||
- Пользователь: <Имя> (@<username>).
|
||||
- Рабочая папка игрока: <project>/Players/<username>
|
||||
- Код проекта не изменять.
|
||||
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
|
||||
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
|
||||
```
|
||||
|
||||
## 2) Приветственное сообщение игроку (один раз)
|
||||
|
||||
```text
|
||||
Привет, <Имя>.
|
||||
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
|
||||
Команда /new начинает новую сессию и архивирует текущую историю.
|
||||
```
|
||||
|
||||
## 3) Отказ неизвестному пользователю
|
||||
|
||||
```text
|
||||
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
|
||||
```
|
||||
@@ -1,100 +0,0 @@
|
||||
# SHiNE-agent-bot-coder
|
||||
|
||||
Локальный Telegram-бот-сервис для пользователя `ai`:
|
||||
- принимает сообщения от `@AidarKC`;
|
||||
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
|
||||
- ведёт историю диалога в `JSONL`;
|
||||
- ставит задачи в файловую очередь;
|
||||
- обрабатывает задачи строго последовательно;
|
||||
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
|
||||
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
||||
- при рестарте восстанавливает незавершённые задачи;
|
||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
|
||||
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
|
||||
|
||||
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
||||
|
||||
## Структура
|
||||
- `.env` — локальные секреты и параметры запуска (не коммитится);
|
||||
- `data/py_queue.jsonl` — очередь Python-сервиса;
|
||||
- `data/py_state.json` — текущее состояние Python-сервиса;
|
||||
- `data/py_processed_updates.log` — дедуп входящих update;
|
||||
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
|
||||
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
|
||||
|
||||
## Локальный запуск
|
||||
1. Скопировать пример:
|
||||
- `cp .env.example .env`
|
||||
2. Заполнить секреты в `.env`.
|
||||
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
||||
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
||||
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
|
||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
||||
- `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда.
|
||||
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
||||
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
||||
- `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`.
|
||||
- `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд.
|
||||
- `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды.
|
||||
- `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`.
|
||||
- `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`.
|
||||
- `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`.
|
||||
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
|
||||
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
|
||||
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
|
||||
- `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд.
|
||||
- `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов.
|
||||
3. Запуск:
|
||||
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
||||
|
||||
## Быстрый self-test Codex (без Telegram)
|
||||
```bash
|
||||
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
|
||||
```
|
||||
|
||||
## Длинные voice/audio
|
||||
- Если аудио короткое, бот отправляет его в OpenAI как раньше.
|
||||
- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно.
|
||||
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
|
||||
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
|
||||
|
||||
## Статусы в личке
|
||||
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
|
||||
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
|
||||
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
|
||||
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
|
||||
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
|
||||
|
||||
## Запуск как systemd-сервис
|
||||
Файлы для установки:
|
||||
- `scripts/systemd/shine-agent-bot-coder.service`
|
||||
- `scripts/systemd/install-local-systemd.sh`
|
||||
|
||||
Установка:
|
||||
- `bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
|
||||
|
||||
Проверка:
|
||||
- `systemctl --user status shine-agent-bot-coder --no-pager`
|
||||
- `journalctl --user -u shine-agent-bot-coder -f`
|
||||
|
||||
Перезапуск после изменений:
|
||||
- `systemctl --user restart shine-agent-bot-coder`
|
||||
|
||||
## Telegram-команды
|
||||
- `/status` — активная задача и размер очереди.
|
||||
- `/settings` — текущие пользовательские настройки и команды для их изменения.
|
||||
- `/queue` — список задач в очереди.
|
||||
- `/stop` — остановить текущую задачу.
|
||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
|
||||
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
||||
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
|
||||
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
|
||||
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
|
||||
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
|
||||
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="/home/ai/work/SHiNE/SHiNE-server-sha256"
|
||||
SERVICE_DIR="${ROOT_DIR}/SHiNE-agent-bot-coder"
|
||||
UNIT_SRC="${SERVICE_DIR}/scripts/systemd/shine-agent-bot-coder.service"
|
||||
UNIT_DST="${HOME}/.config/systemd/user/shine-agent-bot-coder.service"
|
||||
|
||||
echo "[1/6] Проверка python3..."
|
||||
command -v python3 >/dev/null 2>&1 || { echo "python3 не найден"; exit 1; }
|
||||
|
||||
echo "[2/6] Подготовка папки логов..."
|
||||
mkdir -p "${SERVICE_DIR}/logs"
|
||||
|
||||
echo "[3/6] Копирование user systemd unit..."
|
||||
mkdir -p "$(dirname "${UNIT_DST}")"
|
||||
cp "${UNIT_SRC}" "${UNIT_DST}"
|
||||
|
||||
echo "[4/6] daemon-reload..."
|
||||
systemctl --user daemon-reload
|
||||
|
||||
echo "[5/6] enable + start..."
|
||||
systemctl --user enable --now shine-agent-bot-coder
|
||||
|
||||
echo "[6/6] Статус:"
|
||||
systemctl --user status shine-agent-bot-coder --no-pager
|
||||
|
||||
echo "Готово. Логи: journalctl --user -u shine-agent-bot-coder -f"
|
||||
@@ -1,19 +0,0 @@
|
||||
[Unit]
|
||||
Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder
|
||||
EnvironmentFile=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/.env
|
||||
ExecStart=/usr/bin/python3 /home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/py_bot_service.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
SuccessExitStatus=143 0
|
||||
StandardOutput=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
|
||||
StandardError=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -9,7 +9,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
||||
|
||||
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
|
||||
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
|
||||
- `shine-server-db/` — DAO, SQL-схема, SQLite
|
||||
- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime
|
||||
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
|
||||
- `shine-server-crypto/` — криптографические утилиты
|
||||
- `shine-server-config/` — конфигурация сервера
|
||||
@@ -45,39 +45,20 @@ shine-UI/server-ui.html
|
||||
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
||||
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
|
||||
|
||||
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
|
||||
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
|
||||
|
||||
## Синхронизация с партнёрскими серверами
|
||||
|
||||
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
|
||||
Детали: `Dev_Docs/Blockchain/sync-between-servers.md`
|
||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
||||
|
||||
## Деплой
|
||||
|
||||
```
|
||||
./gradlew deployServer
|
||||
./gradlew deployUI
|
||||
```
|
||||
|
||||
Default deploy по умолчанию идёт на `t.shineup.me` (`player@193.8.215.70`).
|
||||
|
||||
Production deploy:
|
||||
|
||||
```
|
||||
./gradlew deployServerProduction
|
||||
./gradlew deployUIProduction
|
||||
```
|
||||
|
||||
Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя.
|
||||
|
||||
Резервный test-контур:
|
||||
|
||||
```
|
||||
./gradlew deployServerTest
|
||||
./gradlew deployUITest
|
||||
```
|
||||
|
||||
`test.shineup.me` считается резервным тестовым сервером и в обычный deploy не включается.
|
||||
- Основные инструкции по деплою находятся в `../deploy/AGENTS.md`.
|
||||
- Deploy выполнять shell-скриптами из `../deploy/scripts/`.
|
||||
- Gradle deploy-задачи не использовать: Gradle остаётся для сборки и локального запуска.
|
||||
- Любые изменения на production (`shineup.me`, `server2.shineup.me`) делать только после отдельного явного подтверждения пользователя.
|
||||
- Перед production deploy обязательно обновить/проверить backup в `deploy/backup/archive/`.
|
||||
|
||||
Логи на проде:
|
||||
- `/home/player/SHiNE/shine-server/logs/app.log`
|
||||
|
||||
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
|
||||
case TextBody.KEY -> {
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
yield new TextLineBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
|
||||
yield new TextReplyBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||
yield new TextRatingBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
||||
}
|
||||
|
||||
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
||||
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
||||
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
|
||||
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
|
||||
|
||||
default -> throw new IllegalArgumentException(String.format(
|
||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||
|
||||
@@ -64,11 +64,29 @@ public final class MsgSubType {
|
||||
*/
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/** RATING — target-based отзыв на конкретный блок. */
|
||||
public static final short TEXT_RATING = 30;
|
||||
|
||||
/**
|
||||
* REPOST — репост сообщения в линии канала.
|
||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
*/
|
||||
public static final short TEXT_REPOST = 30;
|
||||
public static final short TEXT_REPOST = 50;
|
||||
|
||||
/**
|
||||
* CHANNEL_META — скрытый технический снимок профиля канала.
|
||||
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
|
||||
*/
|
||||
public static final short TEXT_CHANNEL_META = 90;
|
||||
|
||||
/** ENTRYPOINT — входная страница канала (line-based). */
|
||||
public static final short TEXT_ENTRYPOINT = 100;
|
||||
/** EXERCISE — упражнение/комплекс (line-based). */
|
||||
public static final short TEXT_EXERCISE = 110;
|
||||
/** SERVICE — услуга/процедура (line-based). */
|
||||
public static final short TEXT_SERVICE = 120;
|
||||
/** COURSE — курс (line-based). */
|
||||
public static final short TEXT_COURSE = 130;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
@@ -80,14 +98,9 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
@@ -138,4 +151,16 @@ public final class MsgSubType {
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||
|
||||
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||
|
||||
public static final short STATUS_DONE_ONCE = 10;
|
||||
public static final short STATUS_LEARNED = 20;
|
||||
public static final short STATUS_SERVICE_PASSED = 30;
|
||||
public static final short STATUS_CONFIRMED = 100;
|
||||
public static final short STATUS_INTERESTED = 110;
|
||||
public static final short STATUS_STARTED = 120;
|
||||
public static final short STATUS_IN_STUDY = 130;
|
||||
public static final short STATUS_ABANDONED = 140;
|
||||
public static final short STATUS_COMPLETED = 150;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ import java.util.Objects;
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.util.Objects;
|
||||
* [1] channelNameLen
|
||||
* [N] channelName UTF-8
|
||||
* [2] channelDescriptionLen
|
||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
||||
* [M] channelDescription UTF-8 (0..2048 bytes)
|
||||
* [2] channelTypeCode (uint16)
|
||||
* [2] channelTypeVersion (uint16)
|
||||
*/
|
||||
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
private static final byte[] ZERO32 = new byte[32];
|
||||
private static final int MAX_NAME_LENGTH = 32;
|
||||
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
||||
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||
}
|
||||
if (bb.remaining() != descriptionLen + 4) {
|
||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
String normalizedDescription = normalizeDescription(channelDescription);
|
||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||
}
|
||||
|
||||
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
||||
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
private static String normalizeDescription(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
}
|
||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||
}
|
||||
|
||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
||||
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
@Override
|
||||
public int lineSeq() { return thisLineNumber; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* StatusActionBody — type=5, ver=1.
|
||||
*
|
||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [M] text UTF-8
|
||||
*/
|
||||
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 5;
|
||||
public static final short VER = 1;
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final String toBlockchainName;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
|
||||
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
|
||||
}
|
||||
if (!isSupportedSubType(subType)) {
|
||||
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||
|
||||
ensureNoTail(bb, "StatusActionBody");
|
||||
}
|
||||
|
||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatusActionBody check() {
|
||||
if (!isSupportedSubType(subType)) {
|
||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
||||
}
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
}
|
||||
if (message == null) throw new IllegalArgumentException("message is null");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
private static boolean isSupportedSubType(short subType) {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|
||||
}
|
||||
|
||||
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) return "";
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||
}
|
||||
|
||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,16 @@ import java.util.Objects;
|
||||
* subType:
|
||||
* - POST (10)
|
||||
* - EDIT_POST (11)
|
||||
* - REPOST (30)
|
||||
* - REPOST (50)
|
||||
* - CHANNEL_META (90)
|
||||
* - ENTRYPOINT (100)
|
||||
* - EXERCISE (110)
|
||||
* - SERVICE (120)
|
||||
* - COURSE (130)
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
*
|
||||
* POST:
|
||||
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
@@ -133,7 +143,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
||||
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
||||
|
||||
ensureNoTail(bb, "TextLineBody");
|
||||
}
|
||||
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||
}
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
|
||||
if (requiresNonBlankMessage(st) && message.isBlank()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
@@ -231,10 +252,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||
} else {
|
||||
if (message == null || message.isBlank())
|
||||
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
||||
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
} else if (message == null) {
|
||||
throw new IllegalArgumentException("Text message is null");
|
||||
}
|
||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST must not contain target fields");
|
||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||
}
|
||||
|
||||
return this;
|
||||
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
|
||||
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
int cap;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||
}
|
||||
|
||||
private static boolean requiresNonBlankMessage(int st) {
|
||||
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextRatingBody — type=1, ver=1.
|
||||
*
|
||||
* subType:
|
||||
* - RATING (30)
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [M] text UTF-8
|
||||
*/
|
||||
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final String toBlockchainName;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
|
||||
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
|
||||
}
|
||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||
|
||||
ensureNoTail(bb, "TextRatingBody");
|
||||
}
|
||||
|
||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
|
||||
this.subType = MsgSubType.TEXT_RATING;
|
||||
this.version = VER;
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextRatingBody check() {
|
||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
||||
}
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
}
|
||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
return s;
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||
}
|
||||
|
||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ public final class AppConfig {
|
||||
public String getParam(String name) {
|
||||
String fromSystem = System.getProperty(name);
|
||||
if (fromSystem != null) return fromSystem;
|
||||
String fromEnv = System.getenv(toEnvName(name));
|
||||
if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim();
|
||||
return properties.getProperty(name);
|
||||
}
|
||||
|
||||
@@ -78,4 +80,11 @@ public final class AppConfig {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
||||
}
|
||||
|
||||
private static String toEnvName(String name) {
|
||||
return name
|
||||
.replace('.', '_')
|
||||
.replace('-', '_')
|
||||
.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
||||
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
||||
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
@@ -31,4 +34,4 @@ public final class ShineSignatureConstants {
|
||||
|
||||
/** Длина публичного ключа Ed25519. */
|
||||
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.xerial:sqlite-jdbc:3.47.0.0' // sqlite
|
||||
implementation 'org.postgresql:postgresql:42.7.7'
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
package shine.db;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.*;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DatabaseInitializer — создание новой SQLite-БД по схеме SHiNE.
|
||||
*
|
||||
* В этой версии:
|
||||
* - создаём ТОЛЬКО таблицы/индексы
|
||||
* - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st)
|
||||
*
|
||||
* v2 (sessions):
|
||||
* - active_sessions.session_pwd удалён
|
||||
* - active_sessions.session_key хранит публичный ключ сессии целиком одной строкой
|
||||
* PostgreSQL runtime schema bootstrapper for SHiNE server.
|
||||
*/
|
||||
public final class DatabaseInitializer {
|
||||
|
||||
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
||||
public static final int SCHEMA_VERSION_1 = 1;
|
||||
public static final int SCHEMA_VERSION_2 = 2;
|
||||
public static final int SCHEMA_VERSION_3 = 3;
|
||||
public static final int SCHEMA_VERSION_4 = 4;
|
||||
public static final int SCHEMA_VERSION_5 = 5;
|
||||
public static final int SCHEMA_VERSION_6 = 6;
|
||||
public static final int SCHEMA_VERSION_7 = 7;
|
||||
public static final int SCHEMA_VERSION_8 = 8;
|
||||
public static final int SCHEMA_VERSION_9 = 9;
|
||||
public static final int SCHEMA_VERSION_10 = 10;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
|
||||
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
|
||||
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
||||
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
|
||||
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
/* ===================== TEXT (msg_type=1) ===================== */
|
||||
|
||||
public static final short TEXT_POST = 10;
|
||||
public static final short TEXT_EDIT_POST = 11;
|
||||
public static final short TEXT_REPLY = 20;
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
public static final short TEXT_RATING = 30;
|
||||
public static final short TEXT_REPOST = 50;
|
||||
public static final short TEXT_CHANNEL_META = 90;
|
||||
public static final short TEXT_ENTRYPOINT = 100;
|
||||
public static final short TEXT_EXERCISE = 110;
|
||||
public static final short TEXT_SERVICE = 120;
|
||||
public static final short TEXT_COURSE = 130;
|
||||
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
// Близкий друг (close friend). Исторически в коде использовалось имя FRIEND.
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
@@ -76,635 +85,249 @@ public final class DatabaseInitializer {
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
public static void createNewDB(String[] args) {
|
||||
AppConfig config = AppConfig.getInstance();
|
||||
String dbPath = config.getParam("db.path");
|
||||
|
||||
if (dbPath == null || dbPath.isBlank()) {
|
||||
System.err.println("Параметр db.path не задан в application.properties");
|
||||
return;
|
||||
}
|
||||
|
||||
Path dbFile = Paths.get(dbPath);
|
||||
public static void ensurePostgresSchemaInitialized(String jdbcUrl,
|
||||
String user,
|
||||
String password) throws SQLException {
|
||||
try {
|
||||
Path parent = dbFile.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
|
||||
if (Files.exists(dbFile)) {
|
||||
System.out.println("Файл базы данных уже существует: " + dbFile.toAbsolutePath());
|
||||
System.out.print("Пересоздать БД (СТАРАЯ БУДЕТ УДАЛЕНА)? [y/N]: ");
|
||||
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
|
||||
String answer = reader.readLine();
|
||||
if (!"y".equalsIgnoreCase(answer) && !"yes".equalsIgnoreCase(answer)) {
|
||||
System.out.println("Операция отменена. БД не изменена.");
|
||||
return;
|
||||
}
|
||||
|
||||
Files.delete(dbFile);
|
||||
System.out.println("Старый файл БД удалён.");
|
||||
}
|
||||
|
||||
createSchema("jdbc:sqlite:" + dbPath);
|
||||
System.out.println("Новая БД успешно создана по пути: " + dbFile.toAbsolutePath());
|
||||
|
||||
} catch (IOException e) {
|
||||
System.err.println("Ошибка работы с файлом БД: " + e.getMessage());
|
||||
} catch (SQLException e) {
|
||||
System.err.println("Ошибка создания схемы БД: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void ensureSchemaV1Structure(String jdbcUrl) throws SQLException {
|
||||
createSchema(jdbcUrl, false);
|
||||
}
|
||||
|
||||
private static void createSchema(String jdbcUrl) throws SQLException {
|
||||
createSchema(jdbcUrl, true);
|
||||
}
|
||||
|
||||
private static void createSchema(String jdbcUrl, boolean initializeVersionRow) throws SQLException {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
Class.forName("org.postgresql.Driver");
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new RuntimeException("SQLite JDBC driver not found", e);
|
||||
throw new RuntimeException("PostgreSQL JDBC driver not found", e);
|
||||
}
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = conn.createStatement()) {
|
||||
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
|
||||
// 1. solana_users
|
||||
// ВАЖНО:
|
||||
// - Все требуемые поля теперь лежат в solana_users:
|
||||
// login, blockchain_name, solana_key, blockchain_key, client_key
|
||||
// - Поиск по login в DAO сделан case-insensitive.
|
||||
// - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY.
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS solana_users (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
blockchain_name TEXT NOT NULL,
|
||||
solana_key TEXT NOT NULL,
|
||||
blockchain_key TEXT NOT NULL,
|
||||
client_key TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_users_blockchain_name
|
||||
ON solana_users (blockchain_name);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_users_login
|
||||
ON solana_users (login);
|
||||
""");
|
||||
|
||||
// 2. active_sessions (v2)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
session_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
storage_pwd TEXT NOT NULL,
|
||||
session_created_at_ms INTEGER NOT NULL,
|
||||
last_authirificated_at_ms INTEGER NOT NULL,
|
||||
push_endpoint TEXT,
|
||||
push_p256dh_key TEXT,
|
||||
push_auth_key TEXT,
|
||||
client_ip TEXT,
|
||||
client_info_from_client TEXT,
|
||||
client_info_from_request TEXT,
|
||||
session_type INTEGER NOT NULL DEFAULT 1,
|
||||
client_platform TEXT NOT NULL DEFAULT '',
|
||||
user_language TEXT,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_active_sessions_login
|
||||
ON active_sessions (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS esp_pairing_settings (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
ttl_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
first_failed_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_until_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS esp_pairing_requests (
|
||||
pairing_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
requester_session_key TEXT NOT NULL,
|
||||
requester_session_type INTEGER NOT NULL DEFAULT 1,
|
||||
requester_client_platform TEXT NOT NULL DEFAULT '',
|
||||
payload_type INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
short_code TEXT NOT NULL,
|
||||
fingerprint_b58 TEXT NOT NULL,
|
||||
encrypted_payload TEXT,
|
||||
reject_reason TEXT,
|
||||
approved_by_session_id TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
expires_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
delivered_to_homeserver INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status
|
||||
ON esp_pairing_requests (login, status, expires_at_ms);
|
||||
""");
|
||||
|
||||
// 3. users_params
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS users_params (
|
||||
login TEXT NOT NULL,
|
||||
param TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
client_key TEXT,
|
||||
signature TEXT,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, param)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_params_login
|
||||
ON users_params (login);
|
||||
""");
|
||||
|
||||
// 4. ip_geo_cache
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT NOT NULL PRIMARY KEY,
|
||||
geo TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at
|
||||
ON ip_geo_cache (updated_at_ms);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
last_tx_id TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
|
||||
ON test_free_avatar_uploads (updated_at_ms);
|
||||
""");
|
||||
|
||||
// 5. blockchain_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS blockchain_state (
|
||||
blockchain_name TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
blockchain_key TEXT NOT NULL,
|
||||
|
||||
size_limit INTEGER NOT NULL,
|
||||
file_size_bytes INTEGER NOT NULL,
|
||||
|
||||
last_block_number INTEGER NOT NULL,
|
||||
last_block_hash BLOB,
|
||||
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blockchain_state_login
|
||||
ON blockchain_state (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
||||
ON blockchain_state (updated_at_ms);
|
||||
""");
|
||||
|
||||
// 6. blocks (+ line_code)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
login TEXT NOT NULL,
|
||||
bch_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK(block_number >= 0),
|
||||
|
||||
msg_type INTEGER NOT NULL,
|
||||
msg_sub_type INTEGER NOT NULL,
|
||||
|
||||
block_bytes BLOB NOT NULL,
|
||||
|
||||
-- target (reply/like/edit и т.д.)
|
||||
to_login TEXT,
|
||||
to_bch_name TEXT,
|
||||
to_block_number INTEGER CHECK(to_block_number IS NULL OR to_block_number >= 0),
|
||||
to_block_hash BLOB,
|
||||
|
||||
-- собственные данные
|
||||
block_hash BLOB NOT NULL,
|
||||
block_signature BLOB NOT NULL,
|
||||
|
||||
-- если этот блок был изменён последним edit'ом
|
||||
edited_by_block_number INTEGER CHECK(edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||
|
||||
-- линейность (опционально)
|
||||
line_code INTEGER CHECK(line_code IS NULL OR line_code >= 0),
|
||||
prev_line_number INTEGER CHECK(prev_line_number IS NULL OR prev_line_number >= 0),
|
||||
prev_line_hash BLOB,
|
||||
this_line_number INTEGER CHECK(this_line_number IS NULL OR this_line_number >= 0),
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (bch_name) REFERENCES blockchain_state(blockchain_name),
|
||||
|
||||
UNIQUE (bch_name, block_number)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||
ON blocks (bch_name, block_number);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||
ON blocks (to_login, to_bch_name, to_block_number);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
""");
|
||||
|
||||
// 7) connections_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
||||
ON connections_state (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
||||
ON connections_state (to_login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
||||
ON connections_state (login, to_login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||
""");
|
||||
|
||||
// 8) message_stats
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS message_stats (
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
|
||||
likes_count INTEGER NOT NULL DEFAULT 0,
|
||||
replies_count INTEGER NOT NULL DEFAULT 0,
|
||||
edits_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE (
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_message_stats_target
|
||||
ON message_stats (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_message_stats_login
|
||||
ON message_stats (to_login);
|
||||
""");
|
||||
|
||||
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||
from_login TEXT NOT NULL,
|
||||
from_bch_name TEXT NOT NULL,
|
||||
reaction_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
last_sub_type INTEGER NOT NULL,
|
||||
|
||||
UNIQUE (
|
||||
from_login,
|
||||
from_bch_name,
|
||||
reaction_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||
""");
|
||||
|
||||
// 9) channel_names_state (global normalized channel names)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
slug TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_description TEXT NOT NULL DEFAULT '',
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||
ON channel_names_state (owner_bch_name, channel_type_code, slug);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||
ON channel_names_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
// 9.1) chat200_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
channel_type_version INTEGER NOT NULL,
|
||||
chat_title TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_bch_name, channel_root_block_number)
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
|
||||
ON chat200_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
// 9.2) chat200_members_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_members_state (
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
member_login TEXT NOT NULL,
|
||||
member_channel_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
updated_by_block_number INTEGER NOT NULL,
|
||||
PRIMARY KEY (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
member_login,
|
||||
member_channel_name
|
||||
)
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
|
||||
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
|
||||
""");
|
||||
|
||||
// 10) direct_messages
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||
message_id TEXT NOT NULL PRIMARY KEY,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login
|
||||
ON direct_messages (to_login, created_at_ms);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login
|
||||
ON direct_messages (from_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 11) user_push_tokens
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS user_push_tokens (
|
||||
token_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
user_agent TEXT,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login
|
||||
ON user_push_tokens (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session
|
||||
ON user_push_tokens (login, session_id);
|
||||
""");
|
||||
|
||||
// 11) signed_direct_message_replay (anti-replay window)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_message_replay (
|
||||
from_login TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
UNIQUE (from_login, time_ms, nonce)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
|
||||
ON signed_direct_message_replay (created_at_ms);
|
||||
""");
|
||||
|
||||
// 12) signed_direct_messages_history (сырой бинарный пакет + мета)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
||||
message_id TEXT NOT NULL PRIMARY KEY,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
target_mode INTEGER NOT NULL,
|
||||
target_session_id TEXT,
|
||||
message_type INTEGER NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
raw_packet BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to
|
||||
ON signed_direct_messages_history (to_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
||||
message_key TEXT NOT NULL PRIMARY KEY,
|
||||
base_key TEXT NOT NULL,
|
||||
target_login TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||
reencrypted_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||
raw_block BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
source_api TEXT NOT NULL,
|
||||
origin_session_id TEXT,
|
||||
receipt_ref_base_key TEXT,
|
||||
receipt_ref_type INTEGER,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_target
|
||||
ON signed_messages_v2 (target_login, time_ms, created_at_ms);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_base
|
||||
ON signed_messages_v2 (base_key, message_type);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
|
||||
// 14) signed_message_session_delivery (доставка по сессиям)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
||||
message_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
delivered INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_at_ms INTEGER,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (message_key, session_id),
|
||||
FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
ON signed_message_session_delivery (session_id, delivered);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
if (initializeVersionRow) {
|
||||
st.executeUpdate("""
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 1, CAST(strftime('%s','now') AS INTEGER) * 1000)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
schema_version = excluded.schema_version,
|
||||
updated_at_ms = excluded.updated_at_ms;
|
||||
""");
|
||||
try (Connection conn = openConnection(jdbcUrl, user, password)) {
|
||||
if (!postgresSchemaVersionTableExists(conn)) {
|
||||
runSqlScript(conn, POSTGRES_SCHEMA_RESOURCE);
|
||||
return;
|
||||
}
|
||||
int currentVersion = readCurrentSchemaVersion(conn);
|
||||
if (currentVersion < SCHEMA_VERSION_2) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_2;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_3) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_3;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_4) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_4;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_5) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_5;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_6) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_6;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_7) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_7;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_8) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_8;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_9) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_9;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_10) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Connection openConnection(String jdbcUrl, String user, String password) throws SQLException {
|
||||
if (user == null || user.isBlank()) {
|
||||
return DriverManager.getConnection(jdbcUrl);
|
||||
}
|
||||
return DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
|
||||
}
|
||||
|
||||
private static boolean postgresSchemaVersionTableExists(Connection conn) throws SQLException {
|
||||
String sql = """
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
)
|
||||
""";
|
||||
try (var ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, DB_SCHEMA_VERSION_TABLE);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() && rs.getBoolean(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int readCurrentSchemaVersion(Connection conn) throws SQLException {
|
||||
String sql = """
|
||||
SELECT schema_version
|
||||
FROM db_schema_version
|
||||
WHERE id = 1
|
||||
""";
|
||||
try (Statement st = conn.createStatement();
|
||||
ResultSet rs = st.executeQuery(sql)) {
|
||||
if (!rs.next()) {
|
||||
return SCHEMA_VERSION_1;
|
||||
}
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runSqlScript(Connection conn, String resourcePath) throws SQLException {
|
||||
String sqlScript = loadClasspathResource(resourcePath);
|
||||
List<String> statements = splitSqlStatements(sqlScript);
|
||||
|
||||
boolean previousAutoCommit = conn.getAutoCommit();
|
||||
conn.setAutoCommit(true);
|
||||
try (Statement st = conn.createStatement()) {
|
||||
for (String statement : statements) {
|
||||
String trimmed = statement.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
st.execute(trimmed);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
private static String loadClasspathResource(String resourcePath) {
|
||||
try (InputStream in = DatabaseInitializer.class.getClassLoader().getResourceAsStream(resourcePath)) {
|
||||
if (in == null) {
|
||||
throw new RuntimeException("Resource not found: " + resourcePath);
|
||||
}
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to read resource: " + resourcePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> splitSqlStatements(String sqlScript) {
|
||||
List<String> statements = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
boolean inSingleQuote = false;
|
||||
boolean inLineComment = false;
|
||||
boolean inBlockComment = false;
|
||||
String dollarQuoteTag = null;
|
||||
|
||||
for (int i = 0; i < sqlScript.length(); i++) {
|
||||
char ch = sqlScript.charAt(i);
|
||||
|
||||
if (inLineComment) {
|
||||
current.append(ch);
|
||||
if (ch == '\n') {
|
||||
inLineComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||
if (inBlockComment) {
|
||||
current.append(ch);
|
||||
if (ch == '*' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '/') {
|
||||
current.append('/');
|
||||
i += 1;
|
||||
inBlockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dollarQuoteTag != null) {
|
||||
if (startsWithAt(sqlScript, i, dollarQuoteTag)) {
|
||||
current.append(dollarQuoteTag);
|
||||
i += dollarQuoteTag.length() - 1;
|
||||
dollarQuoteTag = null;
|
||||
} else {
|
||||
current.append(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingleQuote) {
|
||||
current.append(ch);
|
||||
if (ch == '\'') {
|
||||
if (i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '\'') {
|
||||
current.append('\'');
|
||||
i += 1;
|
||||
} else {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '-' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '-') {
|
||||
current.append("--");
|
||||
i += 1;
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '/' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '*') {
|
||||
current.append("/*");
|
||||
i += 1;
|
||||
inBlockComment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\'') {
|
||||
inSingleQuote = true;
|
||||
current.append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
String tag = readDollarQuoteTag(sqlScript, i);
|
||||
if (tag != null) {
|
||||
current.append(tag);
|
||||
i += tag.length() - 1;
|
||||
dollarQuoteTag = tag;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == ';') {
|
||||
String statement = current.toString().trim();
|
||||
if (!statement.isEmpty()) {
|
||||
statements.add(current.toString());
|
||||
}
|
||||
current.setLength(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
current.append(ch);
|
||||
}
|
||||
|
||||
if (!current.isEmpty()) {
|
||||
String statement = current.toString().trim();
|
||||
if (!statement.isEmpty()) {
|
||||
statements.add(current.toString());
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static boolean startsWithAt(String sqlScript, int index, String token) {
|
||||
return sqlScript.regionMatches(index, token, 0, token.length());
|
||||
}
|
||||
|
||||
private static String readDollarQuoteTag(String sqlScript, int index) {
|
||||
if (sqlScript.charAt(index) != '$') {
|
||||
return null;
|
||||
}
|
||||
|
||||
int end = index + 1;
|
||||
while (end < sqlScript.length()) {
|
||||
char current = sqlScript.charAt(end);
|
||||
if (current == '$') {
|
||||
return sqlScript.substring(index, end + 1);
|
||||
}
|
||||
if (!(Character.isLetterOrDigit(current) || current == '_')) {
|
||||
return null;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
package shine.db;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
||||
*
|
||||
* Мы специально сделали триггеры максимально "совместимыми":
|
||||
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
||||
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
||||
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
||||
* INSERT OR IGNORE + UPDATE
|
||||
* (Старые SQLite не знают UPSERT.)
|
||||
*
|
||||
* =============================================================================
|
||||
* РћРџРРЎРђРќРР• РўР РГГЕРОВ
|
||||
* =============================================================================
|
||||
*
|
||||
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
|
||||
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
||||
*
|
||||
* Зачем это нужно:
|
||||
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
||||
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
||||
* и подтверждает, что ссылка не подменена.
|
||||
*
|
||||
* Когда срабатывает:
|
||||
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
||||
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
||||
*
|
||||
* Что проверяет:
|
||||
* A) line-поля допускаются только для msg_type:
|
||||
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
|
||||
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
||||
* C) prev-блок линии существует в той же цепочке bch_name
|
||||
* D) prev_hash совпадает с block_hash найденного prev-блока
|
||||
* E) line_code корректный:
|
||||
* - либо первый шаг после root: prev_line_number == line_code
|
||||
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
||||
* F) this_line_number:
|
||||
* - первый шаг после root:
|
||||
* TEXT: this_line_number = 0
|
||||
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
|
||||
* - обычный шаг:
|
||||
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
||||
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
||||
*
|
||||
* Какие ошибки кидает:
|
||||
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
|
||||
* - LINE_ERR_PARTIAL_FIELDS
|
||||
* - LINE_ERR_NO_PREV
|
||||
* - LINE_ERR_PREV_HASH_MISMATCH
|
||||
* - LINE_ERR_LINE_CODE_MISMATCH
|
||||
* - LINE_ERR_FIRST_STEP_BAD_THIS
|
||||
* - LINE_ERR_THIS_LINE_BAD_STEP
|
||||
*
|
||||
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
|
||||
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
||||
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
||||
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
||||
*
|
||||
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
|
||||
* Поддерживает likes_count в message_stats для цели (to_*).
|
||||
*
|
||||
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
|
||||
* Поддерживает replies_count в message_stats.
|
||||
*
|
||||
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
|
||||
* Логика edit:
|
||||
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
||||
* - увеличивает edits_count в message_stats
|
||||
*/
|
||||
public final class DatabaseTriggersInstaller {
|
||||
|
||||
private DatabaseTriggersInstaller() {}
|
||||
|
||||
public static void createAllTriggers(Statement st) throws SQLException {
|
||||
dropTriggersByPrefix(st, "trg_blocks_");
|
||||
|
||||
// На всякий случай убираем старые "криво названные" триггеры,
|
||||
// если они когда-то попадали в БД.
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
|
||||
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;");
|
||||
|
||||
createLineIntegrityTrigger(st);
|
||||
createConnectionStateTrigger(st);
|
||||
createMessageStatsLikeTrigger(st);
|
||||
createMessageStatsReplyTrigger(st);
|
||||
createEditApplyTrigger(st);
|
||||
}
|
||||
|
||||
private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException {
|
||||
List<String> triggerNames = new ArrayList<>();
|
||||
String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'";
|
||||
try (ResultSet rs = st.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
triggerNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String name : triggerNames) {
|
||||
String safeName = name.replace("\"", "\"\"");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
|
||||
BEFORE INSERT ON blocks
|
||||
WHEN
|
||||
NEW.line_code IS NOT NULL
|
||||
OR NEW.prev_line_number IS NOT NULL
|
||||
OR NEW.prev_line_hash IS NOT NULL
|
||||
OR NEW.this_line_number IS NOT NULL
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE')
|
||||
WHERE NOT (NEW.msg_type IN (0, 1, 3, 4));
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS')
|
||||
WHERE NEW.line_code IS NULL
|
||||
OR NEW.prev_line_number IS NULL
|
||||
OR NEW.prev_line_hash IS NULL
|
||||
OR NEW.this_line_number IS NULL;
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV')
|
||||
WHERE NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH')
|
||||
WHERE NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
AND p.block_hash = NEW.prev_line_hash
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH')
|
||||
WHERE NEW.prev_line_number <> NEW.line_code
|
||||
AND NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
AND p.line_code = NEW.line_code
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS')
|
||||
WHERE NEW.prev_line_number = NEW.line_code
|
||||
AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP')
|
||||
WHERE NEW.prev_line_number <> NEW.line_code
|
||||
AND NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
AND p.this_line_number IS NOT NULL
|
||||
AND (
|
||||
(NEW.msg_type = 1 AND
|
||||
(NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1)
|
||||
)
|
||||
OR
|
||||
(NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1)
|
||||
)
|
||||
LIMIT 1
|
||||
);
|
||||
END;
|
||||
""");
|
||||
}
|
||||
|
||||
private static void createConnectionStateTrigger(Statement st) throws SQLException {
|
||||
int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND;
|
||||
int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT;
|
||||
int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW;
|
||||
int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE;
|
||||
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
|
||||
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
|
||||
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
|
||||
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
|
||||
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
|
||||
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
|
||||
|
||||
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
|
||||
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
||||
int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW;
|
||||
int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE;
|
||||
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
|
||||
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
|
||||
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
|
||||
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
|
||||
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
|
||||
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 3
|
||||
BEGIN
|
||||
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
|
||||
-- 1) если записи нет — создаём
|
||||
INSERT OR IGNORE INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
)
|
||||
SELECT
|
||||
NEW.login,
|
||||
NEW.msg_sub_type,
|
||||
COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
),
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash
|
||||
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- 2) если запись есть — обновляем актуальные to_*
|
||||
UPDATE connections_state
|
||||
SET
|
||||
to_bch_name = NEW.to_bch_name,
|
||||
to_block_number = NEW.to_block_number,
|
||||
to_block_hash = NEW.to_block_hash
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
|
||||
-- удаляем соответствующее "позитивное" состояние
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND to_login = COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND rel_type = CASE NEW.msg_sub_type
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
ELSE rel_type
|
||||
END
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
|
||||
END;
|
||||
""".formatted(
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
||||
|
||||
UNFRIEND, FRIEND,
|
||||
UNCONTACT, CONTACT,
|
||||
UNFOLLOW, FOLLOW,
|
||||
UNSPOUSE, SPOUSE,
|
||||
UNPARENT, PARENT,
|
||||
UNCHILD, CHILD,
|
||||
UNSIBLING, SIBLING,
|
||||
UNKNOWN, KNOWN,
|
||||
SHINE_UNCONF, SHINE_CONF,
|
||||
SHINE_UNSEEN, SHINE_SEEN,
|
||||
|
||||
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
|
||||
));
|
||||
}
|
||||
|
||||
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
|
||||
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
|
||||
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d)
|
||||
BEGIN
|
||||
-- ensure target stats row exists
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
)
|
||||
SELECT
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
0, 0, 0
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- apply delta by state transition (none/unlike->like = +1, like->unlike = -1)
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
0,
|
||||
likes_count + (
|
||||
CASE
|
||||
WHEN NEW.msg_sub_type = %d
|
||||
AND COALESCE((
|
||||
SELECT b.msg_sub_type
|
||||
FROM blocks b
|
||||
WHERE b.login = NEW.login
|
||||
AND b.bch_name = NEW.bch_name
|
||||
AND b.msg_type = 2
|
||||
AND b.to_login = NEW.to_login
|
||||
AND b.to_bch_name = NEW.to_bch_name
|
||||
AND b.to_block_number = NEW.to_block_number
|
||||
AND b.to_block_hash = NEW.to_block_hash
|
||||
AND b.block_number < NEW.block_number
|
||||
ORDER BY b.block_number DESC
|
||||
LIMIT 1
|
||||
), -1) <> %d
|
||||
THEN 1
|
||||
WHEN NEW.msg_sub_type = %d
|
||||
AND COALESCE((
|
||||
SELECT b.msg_sub_type
|
||||
FROM blocks b
|
||||
WHERE b.login = NEW.login
|
||||
AND b.bch_name = NEW.bch_name
|
||||
AND b.msg_type = 2
|
||||
AND b.to_login = NEW.to_login
|
||||
AND b.to_bch_name = NEW.to_bch_name
|
||||
AND b.to_block_number = NEW.to_block_number
|
||||
AND b.to_block_hash = NEW.to_block_hash
|
||||
AND b.block_number < NEW.block_number
|
||||
ORDER BY b.block_number DESC
|
||||
LIMIT 1
|
||||
), -1) = %d
|
||||
THEN -1
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
)
|
||||
WHERE to_login = NEW.to_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = NEW.to_block_number
|
||||
AND to_block_hash = NEW.to_block_hash
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- persist latest actor->target reaction state
|
||||
INSERT OR IGNORE INTO reactions_state (
|
||||
from_login, from_bch_name, reaction_type,
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
last_sub_type
|
||||
)
|
||||
SELECT
|
||||
NEW.login, NEW.bch_name, %d,
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
NEW.msg_sub_type
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
UPDATE reactions_state
|
||||
SET last_sub_type = NEW.msg_sub_type
|
||||
WHERE from_login = NEW.login
|
||||
AND from_bch_name = NEW.bch_name
|
||||
AND reaction_type = %d
|
||||
AND to_login = NEW.to_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = NEW.to_block_number
|
||||
AND to_block_hash = NEW.to_block_hash
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(
|
||||
LIKE, UNLIKE,
|
||||
LIKE, LIKE,
|
||||
UNLIKE, LIKE,
|
||||
LIKE,
|
||||
LIKE
|
||||
));
|
||||
}
|
||||
|
||||
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
|
||||
int REPLY = (int) DatabaseInitializer.TEXT_REPLY;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
)
|
||||
SELECT
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
0, 0, 0
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
UPDATE message_stats
|
||||
SET replies_count = replies_count + 1
|
||||
WHERE to_login = NEW.to_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = NEW.to_block_number
|
||||
AND to_block_hash = NEW.to_block_hash
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(REPLY));
|
||||
}
|
||||
|
||||
private static void createEditApplyTrigger(Statement st) throws SQLException {
|
||||
int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST;
|
||||
int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
|
||||
BEGIN
|
||||
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
||||
UPDATE blocks
|
||||
SET edited_by_block_number = NEW.block_number
|
||||
WHERE login = NEW.login
|
||||
AND bch_name = NEW.bch_name
|
||||
AND block_number = NEW.to_block_number
|
||||
AND NEW.to_block_number IS NOT NULL;
|
||||
|
||||
-- 2) создаём stats-строку если её не было
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
)
|
||||
SELECT
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
0, 0, 0
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- 3) +1 edit
|
||||
UPDATE message_stats
|
||||
SET edits_count = edits_count + 1
|
||||
WHERE to_login = NEW.to_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = NEW.to_block_number
|
||||
AND to_block_hash = NEW.to_block_hash
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(EDIT_POST, EDIT_REPLY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DbProvider;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Нейтральная точка входа в runtime БД сервера.
|
||||
*
|
||||
* Runtime-сервер теперь поддерживает только PostgreSQL.
|
||||
*/
|
||||
public final class DbController implements DbProvider {
|
||||
|
||||
private static volatile DbController instance;
|
||||
|
||||
private final PostgresDbController delegate;
|
||||
|
||||
private DbController() {
|
||||
this.delegate = PostgresDbController.getInstance();
|
||||
}
|
||||
|
||||
public static DbController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new DbController();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException {
|
||||
return delegate.getConnection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package shine.db;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Нормализация ключей из разных storage-слоёв к каноническому Base64(32).
|
||||
*/
|
||||
public final class KeyEncodingUtil {
|
||||
|
||||
private static final String BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
private static final int[] BASE58_INDEXES = new int[128];
|
||||
|
||||
static {
|
||||
for (int i = 0; i < BASE58_INDEXES.length; i++) {
|
||||
BASE58_INDEXES[i] = -1;
|
||||
}
|
||||
for (int i = 0; i < BASE58_ALPHABET.length(); i++) {
|
||||
BASE58_INDEXES[BASE58_ALPHABET.charAt(i)] = i;
|
||||
}
|
||||
}
|
||||
|
||||
private KeyEncodingUtil() {}
|
||||
|
||||
public static String normalizeKeyToBase64_32(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String value = rawKey.trim();
|
||||
if (value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
byte[] asBase64 = tryDecodeBase64_32(value);
|
||||
if (asBase64 != null) {
|
||||
return Base64.getEncoder().encodeToString(asBase64);
|
||||
}
|
||||
|
||||
byte[] asBase58 = tryDecodeBase58_32(value);
|
||||
if (asBase58 != null) {
|
||||
return Base64.getEncoder().encodeToString(asBase58);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static byte[] tryDecodeBase64_32(String value) {
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(value);
|
||||
return decoded.length == 32 ? decoded : null;
|
||||
} catch (IllegalArgumentException ignore) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] tryDecodeBase58_32(String value) {
|
||||
byte[] decoded = decodeBase58(value);
|
||||
return decoded.length == 32 ? decoded : null;
|
||||
}
|
||||
|
||||
private static byte[] decodeBase58(String input) {
|
||||
if (input.isEmpty()) {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
byte[] input58 = new byte[input.length()];
|
||||
for (int i = 0; i < input.length(); i++) {
|
||||
char c = input.charAt(i);
|
||||
if (c >= BASE58_INDEXES.length || BASE58_INDEXES[c] < 0) {
|
||||
return new byte[0];
|
||||
}
|
||||
input58[i] = (byte) BASE58_INDEXES[c];
|
||||
}
|
||||
|
||||
int zeros = 0;
|
||||
while (zeros < input58.length && input58[zeros] == 0) {
|
||||
zeros++;
|
||||
}
|
||||
|
||||
byte[] decoded = new byte[input.length()];
|
||||
int outputStart = decoded.length;
|
||||
int inputStart = zeros;
|
||||
while (inputStart < input58.length) {
|
||||
int mod = divmod256(input58, inputStart);
|
||||
if (input58[inputStart] == 0) {
|
||||
inputStart++;
|
||||
}
|
||||
decoded[--outputStart] = (byte) mod;
|
||||
}
|
||||
|
||||
while (outputStart < decoded.length && decoded[outputStart] == 0) {
|
||||
outputStart++;
|
||||
}
|
||||
|
||||
byte[] result = new byte[decoded.length - outputStart + zeros];
|
||||
for (int i = 0; i < zeros; i++) {
|
||||
result[i] = 0;
|
||||
}
|
||||
System.arraycopy(decoded, outputStart, result, zeros, decoded.length - outputStart);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int divmod256(byte[] number58, int startAt) {
|
||||
int remainder = 0;
|
||||
for (int i = startAt; i < number58.length; i++) {
|
||||
int digit58 = number58[i] & 0xFF;
|
||||
int temp = remainder * 58 + digit58;
|
||||
number58[i] = (byte) (temp / 256);
|
||||
remainder = temp % 256;
|
||||
}
|
||||
return remainder;
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,23 @@ public final class MsgSubType {
|
||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
||||
public static final short TEXT_REPOST = 30;
|
||||
/** RATING — target-based отзыв на конкретный блок. */
|
||||
public static final short TEXT_RATING = 30;
|
||||
|
||||
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
|
||||
public static final short TEXT_REPOST = 50;
|
||||
|
||||
/** CHANNEL_META — скрытый технический снимок профиля канала. */
|
||||
public static final short TEXT_CHANNEL_META = 90;
|
||||
|
||||
/** ENTRYPOINT — входная страница канала (line-based). */
|
||||
public static final short TEXT_ENTRYPOINT = 100;
|
||||
/** EXERCISE — упражнение/комплекс (line-based). */
|
||||
public static final short TEXT_EXERCISE = 110;
|
||||
/** SERVICE — услуга/процедура (line-based). */
|
||||
public static final short TEXT_SERVICE = 120;
|
||||
/** COURSE — курс (line-based). */
|
||||
public static final short TEXT_COURSE = 130;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
@@ -43,23 +58,17 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
@@ -120,6 +129,18 @@ public final class MsgSubType {
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||
|
||||
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||
|
||||
public static final short STATUS_DONE_ONCE = 10;
|
||||
public static final short STATUS_LEARNED = 20;
|
||||
public static final short STATUS_SERVICE_PASSED = 30;
|
||||
public static final short STATUS_CONFIRMED = 100;
|
||||
public static final short STATUS_INTERESTED = 110;
|
||||
public static final short STATUS_STARTED = 120;
|
||||
public static final short STATUS_IN_STUDY = 130;
|
||||
public static final short STATUS_ABANDONED = 140;
|
||||
public static final short STATUS_COMPLETED = 150;
|
||||
|
||||
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
||||
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
||||
// не трогая уже занятые коды.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DriverManagerDbProvider;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public final class PostgresDbController {
|
||||
|
||||
private static volatile PostgresDbController instance;
|
||||
|
||||
private final DriverManagerDbProvider delegate;
|
||||
|
||||
private PostgresDbController() {
|
||||
AppConfig config = AppConfig.getInstance();
|
||||
String jdbcUrl = trimToNull(config.getParam("db.url"));
|
||||
String dbUser = trimToNull(config.getParam("db.user"));
|
||||
String dbPassword = trimToNull(config.getParam("db.password"));
|
||||
|
||||
if (jdbcUrl == null) {
|
||||
throw new IllegalStateException("Config param 'db.url' is required and must point to PostgreSQL");
|
||||
}
|
||||
if (!jdbcUrl.startsWith("jdbc:postgresql:")) {
|
||||
throw new IllegalStateException("Only PostgreSQL runtime is supported. Unsupported db.url=" + jdbcUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("PostgreSQL schema auto-init failed", e);
|
||||
}
|
||||
|
||||
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
|
||||
connection.setAutoCommit(true);
|
||||
});
|
||||
}
|
||||
|
||||
public static PostgresDbController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (PostgresDbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new PostgresDbController();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Connection getConnection() throws SQLException {
|
||||
return delegate.getConnection();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) return null;
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1,798 +0,0 @@
|
||||
package shine.db;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class SqliteDbController {
|
||||
|
||||
private static volatile SqliteDbController instance;
|
||||
private static final int LATEST_SCHEMA_VERSION = 11;
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
private SqliteDbController() {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new RuntimeException("SQLite JDBC driver not found", e);
|
||||
}
|
||||
|
||||
String dbPath = AppConfig.getInstance().getParam("db.path");
|
||||
if (dbPath == null || dbPath.isBlank()) {
|
||||
throw new RuntimeException("Config param 'db.path' is not set in application.properties");
|
||||
}
|
||||
|
||||
Path dbFile = Paths.get(dbPath);
|
||||
|
||||
if (!Files.exists(dbFile)) {
|
||||
System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath());
|
||||
System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer...");
|
||||
DatabaseInitializer.createNewDB(new String[0]);
|
||||
}
|
||||
|
||||
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
|
||||
ensureSchemaMigrations();
|
||||
}
|
||||
|
||||
public static SqliteDbController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SqliteDbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new SqliteDbController();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Connection getConnection() throws SQLException {
|
||||
Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
conn.setAutoCommit(true);
|
||||
|
||||
try (Statement st = conn.createStatement()) {
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
st.execute("PRAGMA journal_mode = WAL");
|
||||
st.execute("PRAGMA synchronous = NORMAL");
|
||||
st.execute("PRAGMA busy_timeout = 5000");
|
||||
}
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
private void ensureSchemaMigrations() {
|
||||
int currentVersion = getCurrentSchemaVersion();
|
||||
|
||||
while (currentVersion < LATEST_SCHEMA_VERSION) {
|
||||
int nextVersion = currentVersion + 1;
|
||||
applyMigration(nextVersion);
|
||||
currentVersion = nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMigration(int targetVersion) {
|
||||
switch (targetVersion) {
|
||||
case 1 -> migrateToV1();
|
||||
case 2 -> migrateToV2();
|
||||
case 3 -> migrateToV3();
|
||||
case 4 -> migrateToV4();
|
||||
case 5 -> migrateToV5();
|
||||
case 6 -> migrateToV6();
|
||||
case 7 -> migrateToV7();
|
||||
case 8 -> migrateToV8();
|
||||
case 9 -> migrateToV9();
|
||||
case 10 -> migrateToV10();
|
||||
case 11 -> migrateToV11();
|
||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV1() {
|
||||
try {
|
||||
DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v1 failed (base schema)", e);
|
||||
}
|
||||
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
st.execute("PRAGMA foreign_keys = OFF");
|
||||
|
||||
if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) {
|
||||
rebuildConnectionsStateTable(st);
|
||||
}
|
||||
|
||||
ensureChannelNamesDescriptionColumn(c, st);
|
||||
ensureChannelNamesTypeColumns(c, st);
|
||||
ensureSignedMessageReceiptUniq(c, st);
|
||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||
setSchemaVersion(c, 1);
|
||||
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v1 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v1 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV2() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
st.execute("PRAGMA foreign_keys = OFF");
|
||||
st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
|
||||
setSchemaVersion(c, 2);
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v2 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v2 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV3() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureChat200StateTables(st);
|
||||
setSchemaVersion(c, 3);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v3 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v3 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV4() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureActiveSessionsSessionTypeColumn(c, st);
|
||||
ensureActiveSessionsClientPlatformColumn(c, st);
|
||||
setSchemaVersion(c, 4);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v4 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v4 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV5() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureEspPairingTables(st);
|
||||
setSchemaVersion(c, 5);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v5 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v5 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV6() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSignedMessagesRevisionColumn(c, st);
|
||||
setSchemaVersion(c, 6);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v6 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v6 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV7() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
dropDmFileTables(st);
|
||||
setSchemaVersion(c, 7);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v7 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v7 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV8() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureTestFreeAvatarUploadsTable(st);
|
||||
setSchemaVersion(c, 8);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v8 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v8 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV9() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSyncServersTable(st);
|
||||
setSchemaVersion(c, 9);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v9 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v9 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV10() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSignedMessagesReencryptedColumn(c, st);
|
||||
setSchemaVersion(c, 10);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v10 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v10 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV11() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
clearLegacySignedMessagesForDmV11(c, st);
|
||||
setSchemaVersion(c, 11);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v11 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v11 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
channel_type_version INTEGER NOT NULL,
|
||||
chat_title TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_bch_name, channel_root_block_number)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_members_state (
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
member_login TEXT NOT NULL,
|
||||
member_channel_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
updated_by_block_number INTEGER NOT NULL,
|
||||
PRIMARY KEY (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
member_login,
|
||||
member_channel_name
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
|
||||
ON chat200_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
|
||||
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
|
||||
""");
|
||||
}
|
||||
|
||||
private int getCurrentSchemaVersion() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
||||
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try (var ps = c.prepareStatement("""
|
||||
SELECT schema_version
|
||||
FROM db_schema_version
|
||||
WHERE id = 1
|
||||
LIMIT 1
|
||||
""");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Cannot read DB schema version", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException {
|
||||
if (columnExists(c, "active_sessions", "session_type")) return;
|
||||
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
|
||||
private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException {
|
||||
if (columnExists(c, "active_sessions", "client_platform")) return;
|
||||
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
private static void ensureEspPairingTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS esp_pairing_settings (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
ttl_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
first_failed_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_until_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS esp_pairing_requests (
|
||||
pairing_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
requester_session_key TEXT NOT NULL,
|
||||
requester_session_type INTEGER NOT NULL DEFAULT 1,
|
||||
requester_client_platform TEXT NOT NULL DEFAULT '',
|
||||
payload_type INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
short_code TEXT NOT NULL,
|
||||
fingerprint_b58 TEXT NOT NULL,
|
||||
encrypted_payload TEXT,
|
||||
reject_reason TEXT,
|
||||
approved_by_session_id TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
expires_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
delivered_to_homeserver INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status
|
||||
ON esp_pairing_requests (login, status, expires_at_ms);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) {
|
||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) {
|
||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
||||
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
||||
* с новым бинарным форматом и правилами tombstone/reencrypt.
|
||||
* Позже эту миграцию нужно убрать, чтобы случайно не очищать рабочие DM.
|
||||
*/
|
||||
private static void clearLegacySignedMessagesForDmV11(Connection c, Statement st) throws SQLException {
|
||||
if (tableExists(c, "signed_message_session_delivery")) {
|
||||
st.executeUpdate("DELETE FROM signed_message_session_delivery");
|
||||
}
|
||||
if (tableExists(c, "signed_messages_v2")) {
|
||||
st.executeUpdate("DELETE FROM signed_messages_v2");
|
||||
}
|
||||
}
|
||||
|
||||
private static void dropDmFileTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
||||
st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links");
|
||||
st.executeUpdate("DROP TABLE IF EXISTS dm_files");
|
||||
}
|
||||
|
||||
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
||||
while (rs.next()) {
|
||||
if (columnName.equalsIgnoreCase(rs.getString("name"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void setSchemaVersion(Connection c, int version) throws SQLException {
|
||||
try (var ps = c.prepareStatement("""
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
schema_version = excluded.schema_version,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""")) {
|
||||
ps.setInt(1, version);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureReactionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||
from_login TEXT NOT NULL,
|
||||
from_bch_name TEXT NOT NULL,
|
||||
reaction_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
last_sub_type INTEGER NOT NULL,
|
||||
UNIQUE (
|
||||
from_login,
|
||||
from_bch_name,
|
||||
reaction_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
last_tx_id TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
|
||||
ON test_free_avatar_uploads (updated_at_ms);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureSyncServersTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS sync_servers (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
server_address TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_servers_updated
|
||||
ON sync_servers (updated_at_ms);
|
||||
""");
|
||||
}
|
||||
|
||||
|
||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureConnectionsIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
||||
ON connections_state (login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
||||
ON connections_state (to_login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
||||
ON connections_state (login, to_login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureReactionsIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
slug TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_description TEXT NOT NULL DEFAULT '',
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
|
||||
boolean hasDescription = false;
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if ("channel_description".equalsIgnoreCase(name)) {
|
||||
hasDescription = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDescription) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException {
|
||||
boolean hasTypeCode = false;
|
||||
boolean hasTypeVersion = false;
|
||||
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true;
|
||||
if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTypeCode) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
if (!hasTypeVersion) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||
ON channel_names_state (owner_bch_name, channel_type_code, slug);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||
ON channel_names_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
|
||||
if (tableExists(c, "signed_message_session_delivery")) {
|
||||
st.executeUpdate("""
|
||||
DELETE FROM signed_message_session_delivery
|
||||
WHERE message_key IN (
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
GROUP BY target_login, message_type, receipt_ref_base_key
|
||||
)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
st.executeUpdate("""
|
||||
DELETE FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
GROUP BY target_login, message_type, receipt_ref_base_key
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
}
|
||||
|
||||
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE connections_state_v2 (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
INSERT OR IGNORE INTO connections_state_v2
|
||||
(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
SELECT
|
||||
login,
|
||||
rel_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
COALESCE(to_block_number, 0),
|
||||
COALESCE(to_block_hash, zeroblob(32))
|
||||
FROM connections_state
|
||||
WHERE login IS NOT NULL
|
||||
AND to_login IS NOT NULL
|
||||
AND to_bch_name IS NOT NULL;
|
||||
""");
|
||||
|
||||
st.executeUpdate("DROP TABLE connections_state");
|
||||
st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state");
|
||||
}
|
||||
|
||||
private static boolean tableExists(Connection c, String tableName) throws SQLException {
|
||||
String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1";
|
||||
try (var ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, tableName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException {
|
||||
boolean toBlockNumberNotNull = false;
|
||||
boolean toBlockHashNotNull = false;
|
||||
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
int notNull = rs.getInt("notnull");
|
||||
if ("to_block_number".equalsIgnoreCase(name)) {
|
||||
toBlockNumberNotNull = notNull == 1;
|
||||
}
|
||||
if ("to_block_hash".equalsIgnoreCase(name)) {
|
||||
toBlockHashNotNull = notNull == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !toBlockNumberNotNull || !toBlockHashNotNull;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ public final class ChannelNameRules {
|
||||
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||
private static final Pattern PUBLIC_CHANNEL_ALLOWED_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||
|
||||
private ChannelNameRules() {}
|
||||
|
||||
@@ -34,6 +36,14 @@ public final class ChannelNameRules {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String requireValidPublicDisplayNameForCreate(String rawName) {
|
||||
String normalized = requireValidDisplayNameForCreate(rawName);
|
||||
if (!PUBLIC_CHANNEL_ALLOWED_PATTERN.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String toCanonicalSlug(String rawName) {
|
||||
String normalized = normalizeDisplayName(rawName);
|
||||
if (normalized.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package shine.db.connection;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ConnectionFactory {
|
||||
Connection getConnection() throws SQLException;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package shine.db.connection;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Нейтральная точка доступа к JDBC-соединениям без привязки к конкретной БД.
|
||||
*
|
||||
* На первом этапе переносов этот интерфейс нужен как новая опора для DAO,
|
||||
* чтобы постепенно убрать прямую зависимость от DbController.
|
||||
*/
|
||||
public interface DbProvider extends ConnectionFactory, AutoCloseable {
|
||||
|
||||
@Override
|
||||
Connection getConnection() throws SQLException;
|
||||
|
||||
@Override
|
||||
default void close() throws Exception {
|
||||
// По умолчанию ресурсов на уровне provider нет.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package shine.db.connection;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Базовый JDBC provider поверх DriverManager.
|
||||
*
|
||||
* Не знает ничего про конкретный доменный режим БД:
|
||||
* конкретные параметры и post-connect инициализация задаются снаружи.
|
||||
*/
|
||||
public final class DriverManagerDbProvider implements DbProvider {
|
||||
|
||||
private final String jdbcUrl;
|
||||
private final String user;
|
||||
private final String password;
|
||||
private final ConnectionInitializer initializer;
|
||||
|
||||
public DriverManagerDbProvider(String jdbcUrl,
|
||||
String user,
|
||||
String password,
|
||||
ConnectionInitializer initializer) {
|
||||
if (jdbcUrl == null || jdbcUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("jdbcUrl is blank");
|
||||
}
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
this.user = blankToNull(user);
|
||||
this.password = blankToNull(password);
|
||||
this.initializer = initializer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException {
|
||||
Connection connection;
|
||||
if (user == null) {
|
||||
connection = DriverManager.getConnection(jdbcUrl);
|
||||
} else {
|
||||
connection = DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
|
||||
}
|
||||
|
||||
if (initializer != null) {
|
||||
initializer.initialize(connection);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
if (value == null) return null;
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ConnectionInitializer {
|
||||
void initialize(Connection connection) throws SQLException;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
|
||||
import java.sql.*;
|
||||
@@ -17,7 +17,7 @@ import java.util.List;
|
||||
public final class ActiveSessionsDAO {
|
||||
|
||||
private static volatile ActiveSessionsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ActiveSessionsDAO() { }
|
||||
|
||||
@@ -137,7 +137,7 @@ public final class ActiveSessionsDAO {
|
||||
client_platform,
|
||||
user_language
|
||||
FROM active_sessions
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""";
|
||||
|
||||
List<ActiveSessionEntry> result = new ArrayList<>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DatabaseInitializer;
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -24,12 +24,12 @@ import java.sql.SQLException;
|
||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||
* SQLite не может атомарно закоммитить и SQL, и файловую систему сразу;
|
||||
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||
* отдельным recovery/resync-слоем после успешного commit.
|
||||
*
|
||||
* Важный смысл текущей реализации:
|
||||
* - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы;
|
||||
* - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы;
|
||||
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
||||
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
||||
*/
|
||||
@@ -39,7 +39,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
|
||||
private static volatile BlockchainResyncCleanupDAO instance;
|
||||
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private BlockchainResyncCleanupDAO() {}
|
||||
|
||||
@@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
SET likes_count = GREATEST(
|
||||
0,
|
||||
likes_count - (
|
||||
SELECT COUNT(*)
|
||||
likes_count - COALESCE((
|
||||
SELECT COUNT(*)::int
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
@@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
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 <> ?
|
||||
)
|
||||
), 0)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET replies_count = MAX(
|
||||
SET replies_count = GREATEST(
|
||||
0,
|
||||
replies_count - COALESCE((
|
||||
SELECT COUNT(*)
|
||||
SELECT COUNT(*)::int
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
@@ -307,7 +307,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM connections_state
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""", login);
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM users_params
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""", login);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.sql.*;
|
||||
@@ -10,7 +10,7 @@ import java.util.List;
|
||||
public final class BlockchainStateDAO {
|
||||
|
||||
private static volatile BlockchainStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private BlockchainStateDAO() {}
|
||||
|
||||
@@ -75,7 +75,7 @@ public final class BlockchainStateDAO {
|
||||
last_block_hash,
|
||||
updated_at_ms
|
||||
FROM blockchain_state
|
||||
ORDER BY blockchain_name COLLATE NOCASE
|
||||
ORDER BY LOWER(blockchain_name)
|
||||
""";
|
||||
|
||||
List<BlockchainStateEntry> result = new ArrayList<>();
|
||||
@@ -142,7 +142,7 @@ public final class BlockchainStateDAO {
|
||||
* Строгая вставка state только если записи ещё нет.
|
||||
*
|
||||
* Нужна для recovery / resync:
|
||||
* - identity пользователя уже может существовать в solana_users;
|
||||
* - runtime-проекция пользователя уже может существовать в current users слое;
|
||||
* - в таком случае нам надо восстановить только blockchain_state;
|
||||
* - если запись уже есть, метод просто ничего не меняет.
|
||||
*/
|
||||
@@ -229,7 +229,7 @@ public final class BlockchainStateDAO {
|
||||
|
||||
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
|
||||
if (b != null) ps.setBytes(index, b);
|
||||
else ps.setNull(index, Types.BLOB);
|
||||
else ps.setNull(index, Types.BINARY);
|
||||
}
|
||||
|
||||
private static String nn(String s) { return s == null ? "" : s; }
|
||||
|
||||
@@ -2,7 +2,7 @@ package shine.db.dao;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.sql.*;
|
||||
@@ -22,7 +22,7 @@ import java.util.List;
|
||||
public final class BlocksDAO {
|
||||
|
||||
private static volatile BlocksDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
|
||||
|
||||
private BlocksDAO() { }
|
||||
@@ -90,7 +90,7 @@ public final class BlocksDAO {
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
|
||||
else ps.setNull(i++, Types.BLOB);
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
ps.setBytes(i++, e.getBlockHash());
|
||||
ps.setBytes(i++, e.getBlockSignature());
|
||||
@@ -106,7 +106,7 @@ public final class BlocksDAO {
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
|
||||
else ps.setNull(i++, Types.BLOB);
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -11,7 +11,7 @@ import java.util.List;
|
||||
|
||||
public final class ChannelNameStateDAO {
|
||||
private static volatile ChannelNameStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ChannelNameStateDAO() {}
|
||||
|
||||
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
|
||||
channel_type_version,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
created_at_ms,
|
||||
ava_ar,
|
||||
ava_sha256,
|
||||
ava_size,
|
||||
meta_updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getSlug());
|
||||
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
|
||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||
ps.setLong(10, entry.getCreatedAtMs());
|
||||
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
|
||||
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE channel_names_state
|
||||
SET display_name = ?,
|
||||
channel_description = ?,
|
||||
ava_ar = ?,
|
||||
ava_sha256 = ?,
|
||||
ava_size = ?,
|
||||
meta_updated_at_ms = ?
|
||||
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
|
||||
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
|
||||
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||
ps.setString(7, entry.getOwnerBlockchainName());
|
||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.sql.CurrentUsersSql;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -13,16 +14,14 @@ import java.util.List;
|
||||
* ConnectionsStateDAO — чтение текущего состояния связей из connections_state.
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - login в запросах может быть в любом регистре, поэтому в WHERE используем COLLATE NOCASE
|
||||
* - в ответах возвращаем логины в каноническом регистре через JOIN на solana_users
|
||||
*
|
||||
* ПРИМЕЧАНИЕ:
|
||||
* Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй в SQL.
|
||||
* - login в запросах может быть в любом регистре;
|
||||
* - в ответах возвращаем логины в каноническом регистре через прямой lookup
|
||||
* по актуальным PDA-данным и manual fallback.
|
||||
*/
|
||||
public final class ConnectionsStateDAO {
|
||||
|
||||
private static volatile ConnectionsStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ConnectionsStateDAO() {}
|
||||
|
||||
@@ -42,14 +41,17 @@ public final class ConnectionsStateDAO {
|
||||
String sql = """
|
||||
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
||||
FROM connections_state cs
|
||||
LEFT JOIN solana_users u_login
|
||||
ON u_login.login = cs.to_login COLLATE NOCASE
|
||||
LEFT JOIN solana_users u_bch
|
||||
ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
|
||||
WHERE cs.login = ? COLLATE NOCASE
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
||||
WHERE LOWER(cs.login) = LOWER(?)
|
||||
AND cs.rel_type = ?
|
||||
ORDER BY friend_login
|
||||
""";
|
||||
""".formatted(
|
||||
CurrentUsersSql.usersSubquery("u_login"),
|
||||
CurrentUsersSql.usersSubquery("u_bch")
|
||||
);
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -72,17 +74,20 @@ public final class ConnectionsStateDAO {
|
||||
String sql = """
|
||||
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
||||
FROM connections_state cs
|
||||
LEFT JOIN solana_users u_actor
|
||||
ON u_actor.login = cs.login COLLATE NOCASE
|
||||
LEFT JOIN solana_users u_target
|
||||
ON u_target.login = ? COLLATE NOCASE
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_actor.login) = LOWER(cs.login)
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_target.login) = LOWER(?)
|
||||
WHERE (
|
||||
cs.to_login = ? COLLATE NOCASE
|
||||
OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
|
||||
LOWER(cs.to_login) = LOWER(?)
|
||||
OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name))
|
||||
)
|
||||
AND cs.rel_type = ?
|
||||
ORDER BY friend_login
|
||||
""";
|
||||
""".formatted(
|
||||
CurrentUsersSql.usersSubquery("u_actor"),
|
||||
CurrentUsersSql.usersSubquery("u_target")
|
||||
);
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -106,19 +111,19 @@ public final class ConnectionsStateDAO {
|
||||
String sql = """
|
||||
SELECT u.login AS friend_login
|
||||
FROM connections_state a
|
||||
JOIN solana_users u
|
||||
ON u.login = a.to_login COLLATE NOCASE
|
||||
WHERE a.login = ? COLLATE NOCASE
|
||||
JOIN %s
|
||||
ON LOWER(u.login) = LOWER(a.to_login)
|
||||
WHERE LOWER(a.login) = LOWER(?)
|
||||
AND a.rel_type = ?
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state b
|
||||
WHERE b.login = a.to_login COLLATE NOCASE
|
||||
AND b.to_login = a.login COLLATE NOCASE
|
||||
WHERE LOWER(b.login) = LOWER(a.to_login)
|
||||
AND LOWER(b.to_login) = LOWER(a.login)
|
||||
AND b.rel_type = a.rel_type
|
||||
)
|
||||
ORDER BY u.login
|
||||
""";
|
||||
""".formatted(CurrentUsersSql.usersSubquery("u"));
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -141,10 +146,22 @@ public final class ConnectionsStateDAO {
|
||||
String toBchName,
|
||||
Integer toBlockNumber,
|
||||
byte[] toBlockHash) throws SQLException {
|
||||
try (PreparedStatement deletePs = c.prepareStatement("""
|
||||
DELETE FROM connections_state
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND rel_type = ?
|
||||
AND LOWER(to_login) = LOWER(?)
|
||||
""")) {
|
||||
deletePs.setString(1, login);
|
||||
deletePs.setInt(2, relType);
|
||||
deletePs.setString(3, toLogin);
|
||||
deletePs.executeUpdate();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login, rel_type, to_login) DO UPDATE SET
|
||||
ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET
|
||||
to_bch_name=excluded.to_bch_name,
|
||||
to_block_number=excluded.to_block_number,
|
||||
to_block_hash=excluded.to_block_hash
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import shine.db.DbController;
|
||||
import shine.db.KeyEncodingUtil;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.sql.CurrentUsersSql;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SolanaUsersDAO — локальная таблица пользователей из Solana.
|
||||
* CurrentUsersDAO — совместимый runtime-доступ к текущему срезу пользователей.
|
||||
*
|
||||
* Таблица: solana_users
|
||||
* Источник:
|
||||
* - solana_user_pda_current
|
||||
*
|
||||
* Колонки:
|
||||
* - login TEXT PRIMARY KEY (COLLATE NOCASE)
|
||||
* - login TEXT PRIMARY KEY
|
||||
* - blockchain_name TEXT NOT NULL
|
||||
* - solana_key TEXT NOT NULL
|
||||
* - blockchain_key TEXT NOT NULL
|
||||
@@ -23,59 +26,32 @@ import java.util.List;
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*/
|
||||
public final class SolanaUsersDAO {
|
||||
public final class CurrentUsersDAO {
|
||||
|
||||
private static volatile SolanaUsersDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private static volatile CurrentUsersDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SolanaUsersDAO() {}
|
||||
private CurrentUsersDAO() {}
|
||||
|
||||
public static SolanaUsersDAO getInstance() {
|
||||
public static CurrentUsersDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SolanaUsersDAO.class) {
|
||||
if (instance == null) instance = new SolanaUsersDAO();
|
||||
synchronized (CurrentUsersDAO.class) {
|
||||
if (instance == null) instance = new CurrentUsersDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- INSERT --------------------
|
||||
|
||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void insert(Connection c, SolanaUserEntry user) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO solana_users (
|
||||
login, blockchain_name, solana_key, blockchain_key, client_key
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, user.getLogin());
|
||||
ps.setString(2, user.getBlockchainName());
|
||||
ps.setString(3, user.getSolanaKey());
|
||||
ps.setString(4, user.getBlockchainKey());
|
||||
ps.setString(5, user.getClientKey());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void insert(SolanaUserEntry user) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, user);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- EXISTS --------------------
|
||||
|
||||
/** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||
public boolean existsByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM solana_users
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
FROM %s
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""";
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
@@ -96,10 +72,10 @@ public final class SolanaUsersDAO {
|
||||
public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM solana_users
|
||||
FROM %s
|
||||
WHERE blockchain_name = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
@@ -119,7 +95,7 @@ public final class SolanaUsersDAO {
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
/** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||
public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException {
|
||||
public CurrentUserEntry getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
@@ -127,9 +103,9 @@ public final class SolanaUsersDAO {
|
||||
solana_key,
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM solana_users
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""";
|
||||
FROM %s
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
@@ -141,14 +117,14 @@ public final class SolanaUsersDAO {
|
||||
}
|
||||
|
||||
/** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */
|
||||
public SolanaUserEntry getByLogin(String login) throws SQLException {
|
||||
public CurrentUserEntry getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||
public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
public CurrentUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
@@ -156,9 +132,9 @@ public final class SolanaUsersDAO {
|
||||
solana_key,
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM solana_users
|
||||
FROM %s
|
||||
WHERE blockchain_name = ?
|
||||
""";
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
@@ -170,14 +146,19 @@ public final class SolanaUsersDAO {
|
||||
}
|
||||
|
||||
/** Получить по blockchain_name без внешнего соединения. */
|
||||
public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||
public CurrentUserEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByBlockchainName(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
||||
public List<SolanaUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||
return searchByLoginPrefix(c, prefix, null);
|
||||
}
|
||||
|
||||
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
@@ -185,16 +166,24 @@ public final class SolanaUsersDAO {
|
||||
solana_key,
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM solana_users
|
||||
WHERE LOWER(login) LIKE ?
|
||||
FROM %s
|
||||
WHERE normalized_login LIKE LOWER(BTRIM(?))
|
||||
AND (? IS NULL OR is_server = ?)
|
||||
ORDER BY login
|
||||
LIMIT 5
|
||||
""";
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
List<SolanaUserEntry> result = new ArrayList<>();
|
||||
List<CurrentUserEntry> result = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, prefix.toLowerCase() + "%");
|
||||
ps.setString(1, prefix.trim() + "%");
|
||||
if (isServer == null) {
|
||||
ps.setNull(2, Types.BOOLEAN);
|
||||
ps.setNull(3, Types.BOOLEAN);
|
||||
} else {
|
||||
ps.setBoolean(2, isServer);
|
||||
ps.setBoolean(3, isServer);
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(mapRow(rs));
|
||||
}
|
||||
@@ -204,23 +193,28 @@ public final class SolanaUsersDAO {
|
||||
}
|
||||
|
||||
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
||||
public List<SolanaUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||
return searchByLoginPrefix(prefix, null);
|
||||
}
|
||||
|
||||
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return searchByLoginPrefix(c, prefix);
|
||||
return searchByLoginPrefix(c, prefix, isServer);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- MAPPER --------------------
|
||||
|
||||
private SolanaUserEntry mapRow(ResultSet rs) throws SQLException {
|
||||
SolanaUserEntry e = new SolanaUserEntry();
|
||||
private CurrentUserEntry mapRow(ResultSet rs) throws SQLException {
|
||||
CurrentUserEntry e = new CurrentUserEntry();
|
||||
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setBlockchainName(rs.getString("blockchain_name"));
|
||||
e.setSolanaKey(rs.getString("solana_key"));
|
||||
e.setBlockchainKey(rs.getString("blockchain_key"));
|
||||
e.setClientKey(rs.getString("client_key"));
|
||||
e.setSolanaKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("solana_key")));
|
||||
e.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
|
||||
e.setClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key")));
|
||||
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class DirectMessagesDAO {
|
||||
private static volatile DirectMessagesDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private DirectMessagesDAO() {}
|
||||
|
||||
public static DirectMessagesDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DirectMessagesDAO.class) {
|
||||
if (instance == null) instance = new DirectMessagesDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(DirectMessageEntry entry) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO direct_messages (
|
||||
message_id, from_login, to_login, text, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getMessageId());
|
||||
ps.setString(2, entry.getFromLogin());
|
||||
ps.setString(3, entry.getToLogin());
|
||||
ps.setString(4, entry.getText());
|
||||
ps.setLong(5, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
return ps.executeQuery().next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DmSyncPeerStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public final class DmSyncPeerStateDAO {
|
||||
private static volatile DmSyncPeerStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DmSyncPeerStateDAO() {}
|
||||
|
||||
public static DmSyncPeerStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmSyncPeerStateDAO.class) {
|
||||
if (instance == null) instance = new DmSyncPeerStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
|
||||
if (existing != null) {
|
||||
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
|
||||
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
|
||||
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
|
||||
existing.setRemoteServerUrl(remoteServerUrl);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||
ps.setLong(4, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
existing = get(ownerLogin, remoteServerLogin);
|
||||
if (existing == null) {
|
||||
throw new SQLException("Failed to create dm_sync_peer_state row");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
public void updateSuccess(
|
||||
String ownerLogin,
|
||||
String remoteServerLogin,
|
||||
String remoteServerUrl,
|
||||
long cursorStoredAtMs,
|
||||
String cursorMessageKey,
|
||||
boolean bootstrapCompleted
|
||||
) throws SQLException {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
|
||||
cursor_message_key = EXCLUDED.cursor_message_key,
|
||||
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||
last_error = NULL,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
|
||||
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
|
||||
ps.setBoolean(6, bootstrapCompleted);
|
||||
ps.setLong(7, nowMs);
|
||||
ps.setLong(8, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||
ps.setString(4, truncate(error));
|
||||
ps.setLong(5, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
FROM dm_sync_peer_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(remote_server_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
|
||||
e.setCursorMessageKey(rs.getString("cursor_message_key"));
|
||||
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||
long lastSyncAt = rs.getLong("last_sync_at_ms");
|
||||
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE dm_sync_peer_state
|
||||
SET remote_server_url = ?, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(remote_server_login) = LOWER(?)
|
||||
""")) {
|
||||
ps.setString(1, remoteServerUrl);
|
||||
ps.setLong(2, System.currentTimeMillis());
|
||||
ps.setString(3, ownerLogin);
|
||||
ps.setString(4, remoteServerLogin);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncate(String value) {
|
||||
if (value == null) return null;
|
||||
String clean = value.trim();
|
||||
if (clean.length() <= 500) return clean;
|
||||
return clean.substring(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.EspPairingRequestEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -13,7 +13,7 @@ import java.util.List;
|
||||
public final class EspPairingRequestsDAO {
|
||||
|
||||
private static volatile EspPairingRequestsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private EspPairingRequestsDAO() { }
|
||||
|
||||
@@ -110,7 +110,7 @@ public final class EspPairingRequestsDAO {
|
||||
payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason,
|
||||
approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver
|
||||
FROM esp_pairing_requests
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND expires_at_ms > ?
|
||||
AND status = 'created'
|
||||
ORDER BY created_at_ms DESC
|
||||
@@ -131,7 +131,7 @@ public final class EspPairingRequestsDAO {
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT COUNT(*)
|
||||
FROM esp_pairing_requests
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND created_at_ms >= ?
|
||||
AND status IN (
|
||||
""");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.EspPairingSettingsEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -11,7 +11,7 @@ import java.sql.SQLException;
|
||||
public final class EspPairingSettingsDAO {
|
||||
|
||||
private static volatile EspPairingSettingsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private EspPairingSettingsDAO() { }
|
||||
|
||||
@@ -74,7 +74,7 @@ public final class EspPairingSettingsDAO {
|
||||
String sql = """
|
||||
SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms
|
||||
FROM esp_pairing_settings
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.IpGeoCacheEntry;
|
||||
|
||||
import java.sql.*;
|
||||
@@ -20,7 +20,7 @@ import java.sql.*;
|
||||
public final class IpGeoCacheDAO {
|
||||
|
||||
private static volatile IpGeoCacheDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private IpGeoCacheDAO() { }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -11,7 +11,7 @@ import java.util.List;
|
||||
|
||||
public final class PushTokensDAO {
|
||||
private static volatile PushTokensDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private PushTokensDAO() {}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDirectMessagesHistoryDAO {
|
||||
private static volatile SignedDirectMessagesHistoryDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SignedDirectMessagesHistoryDAO() {}
|
||||
|
||||
public static SignedDirectMessagesHistoryDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedDirectMessagesHistoryDAO.class) {
|
||||
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_direct_messages_history (
|
||||
message_id, from_login, to_login, target_mode, target_session_id,
|
||||
message_type, time_ms, nonce, raw_packet, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageId());
|
||||
ps.setString(2, e.getFromLogin());
|
||||
ps.setString(3, e.getToLogin());
|
||||
ps.setInt(4, e.getTargetMode());
|
||||
ps.setString(5, e.getTargetSessionId());
|
||||
ps.setInt(6, e.getMessageType());
|
||||
ps.setLong(7, e.getTimeMs());
|
||||
ps.setLong(8, e.getNonce());
|
||||
ps.setBytes(9, e.getRawPacket());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDmReplayDAO {
|
||||
private static volatile SignedDmReplayDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SignedDmReplayDAO() {}
|
||||
|
||||
public static SignedDmReplayDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedDmReplayDAO.class) {
|
||||
if (instance == null) instance = new SignedDmReplayDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
|
||||
cleanupExpired(nowMs - 15L * 60L * 1000L);
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_direct_message_replay (
|
||||
from_login, time_ms, nonce, created_at_ms
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setLong(2, timeMs);
|
||||
ps.setLong(3, nonce);
|
||||
ps.setLong(4, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanupExpired(long minCreatedAtMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, minCreatedAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class SignedMessagesDAO {
|
||||
|
||||
public enum ApplyStatus {
|
||||
APPLIED,
|
||||
DUPLICATE_OR_OLDER,
|
||||
BLOCKED_BY_MESSAGE_TOMBSTONE,
|
||||
BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
|
||||
public boolean applied() {
|
||||
return this == APPLIED;
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile SignedMessagesDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SignedMessagesDAO() {}
|
||||
|
||||
public static SignedMessagesDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedMessagesDAO.class) {
|
||||
if (instance == null) instance = new SignedMessagesDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ApplyStatus insertIfAbsent(SignedMessageEntry e) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
String sql = """
|
||||
INSERT INTO signed_messages (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
if (status.applied()) {
|
||||
markMessageReadByReceipt(c, e);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean insertPairBothOrNothing(SignedMessageEntry first, SignedMessageEntry second) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
int insertedFirst = insertStrict(c, first);
|
||||
int insertedSecond = insertStrict(c, second);
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
markMessageReadByReceipt(c, first);
|
||||
markMessageReadByReceipt(c, second);
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
c.rollback();
|
||||
return false;
|
||||
} catch (SQLException sqlEx) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (isConstraintViolation(sqlEx)) {
|
||||
return false;
|
||||
}
|
||||
throw sqlEx;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus upsertContentPair(SignedMessageEntry incoming, SignedMessageEntry outgoing) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
|
||||
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
upsertMessage(c, outgoing);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
markMessageReadByReceipt(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus upsertIncomingCopy(SignedMessageEntry incoming) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
|
||||
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteMessage(SignedMessageEntry tombstone) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteConversation(SignedMessageEntry tombstone) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public SignedMessageEntry getByMessageKey(String messageKey) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM %s
|
||||
WHERE message_key = ?
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SignedMessageEntry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getLatestConversationDelete(c, fromLogin, toLogin);
|
||||
}
|
||||
}
|
||||
|
||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||
ensureDeliveryRows(messageKey, List.of(sessionId), nowMs);
|
||||
}
|
||||
|
||||
public void ensureDeliveryRows(String messageKey, List<String> sessionIds, long nowMs) throws Exception {
|
||||
if (sessionIds == null || sessionIds.isEmpty()) return;
|
||||
withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (String sessionId : sessionIds) {
|
||||
if (sessionId == null || sessionId.isBlank()) continue;
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
||||
withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(message_key, session_id) DO UPDATE SET
|
||||
delivered = 1,
|
||||
delivered_at_ms = CASE
|
||||
WHEN signed_message_session_delivery.delivered_at_ms IS NULL THEN excluded.delivered_at_ms
|
||||
WHEN signed_message_session_delivery.delivered_at_ms > excluded.delivered_at_ms THEN excluded.delivered_at_ms
|
||||
ELSE signed_message_session_delivery.delivered_at_ms
|
||||
END
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, deliveredAtMs);
|
||||
ps.setLong(4, deliveredAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public List<SignedMessageEntry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String fillSql = """
|
||||
INSERT INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
)
|
||||
SELECT m.message_key, ?, 0, NULL, ?
|
||||
FROM %s m
|
||||
WHERE (
|
||||
(m.message_type IN (1, 3) AND LOWER(m.to_login) = LOWER(?))
|
||||
OR (m.message_type IN (2, 4) AND LOWER(m.from_login) = LOWER(?))
|
||||
OR (m.message_type IN (5, 6, 7, 8)
|
||||
AND (LOWER(m.from_login) = LOWER(?) OR LOWER(m.to_login) = LOWER(?)))
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""".formatted(messagesTable());
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setLong(2, now);
|
||||
ps.setString(3, login);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, login);
|
||||
ps.setString(6, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
|
||||
FROM %s m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||
""".formatted(messagesTable());
|
||||
List<SignedMessageEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public List<SignedMessageEntry> listConversationPage(
|
||||
String login,
|
||||
String peerLogin,
|
||||
long beforeTimeMs,
|
||||
String beforeMessageKey,
|
||||
int limit
|
||||
) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM %s
|
||||
WHERE LOWER(target_login) = LOWER(?)
|
||||
AND message_type IN (1, 2)
|
||||
AND (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND (
|
||||
? <= 0
|
||||
OR time_ms < ?
|
||||
OR (time_ms = ? AND (? = '' OR message_key < ?))
|
||||
)
|
||||
ORDER BY time_ms DESC, message_key DESC
|
||||
LIMIT ?
|
||||
""".formatted(messagesTable());
|
||||
List<SignedMessageEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setString(2, login);
|
||||
ps.setString(3, peerLogin);
|
||||
ps.setString(4, peerLogin);
|
||||
ps.setString(5, login);
|
||||
ps.setLong(6, beforeTimeMs);
|
||||
ps.setLong(7, beforeTimeMs);
|
||||
ps.setLong(8, beforeTimeMs);
|
||||
ps.setString(9, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||
ps.setString(10, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||
ps.setInt(11, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
public SyncBatch listSyncBatch(
|
||||
String ownerLogin,
|
||||
long afterStoredAtMs,
|
||||
String afterMessageKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 500));
|
||||
int sqlLimit = safeLimit + 1;
|
||||
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM %s
|
||||
WHERE (
|
||||
LOWER(target_login) = LOWER(?)
|
||||
OR (
|
||||
message_type IN (5, 6, 7, 8)
|
||||
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
)
|
||||
AND (
|
||||
created_at_ms > ?
|
||||
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
|
||||
)
|
||||
ORDER BY created_at_ms ASC, message_key ASC
|
||||
LIMIT ?
|
||||
""".formatted(messagesTable());
|
||||
List<SignedMessageEntry> out = new ArrayList<>();
|
||||
boolean hasMore = false;
|
||||
int usedBytes = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, ownerLogin);
|
||||
ps.setLong(4, Math.max(0L, afterStoredAtMs));
|
||||
ps.setLong(5, Math.max(0L, afterStoredAtMs));
|
||||
ps.setString(6, safeAfterMessageKey);
|
||||
ps.setString(7, safeAfterMessageKey);
|
||||
ps.setInt(8, sqlLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
SignedMessageEntry row = mapRow(rs);
|
||||
byte[] raw = row.getRawBlock();
|
||||
int rowBytes = raw == null ? 0 : raw.length;
|
||||
if (out.size() >= safeLimit) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
out.add(row);
|
||||
usedBytes += rowBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SyncBatch(out, hasMore, usedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO %s (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_key) DO UPDATE SET
|
||||
base_key = excluded.base_key,
|
||||
target_login = excluded.target_login,
|
||||
from_login = excluded.from_login,
|
||||
to_login = excluded.to_login,
|
||||
time_ms = excluded.time_ms,
|
||||
nonce = excluded.nonce,
|
||||
message_type = excluded.message_type,
|
||||
revision_time_ms = excluded.revision_time_ms,
|
||||
reencrypted_at_ms = excluded.reencrypted_at_ms,
|
||||
raw_block = excluded.raw_block,
|
||||
created_at_ms = excluded.created_at_ms,
|
||||
source_api = excluded.source_api,
|
||||
origin_session_id = excluded.origin_session_id,
|
||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||
receipt_ref_type = excluded.receipt_ref_type,
|
||||
read_at_ms = COALESCE(%s.read_at_ms, excluded.read_at_ms)
|
||||
""".formatted(messagesTable(), messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void markMessageReadByReceipt(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||
if (entry == null) return;
|
||||
int messageType = entry.getMessageType();
|
||||
if (messageType != 3 && messageType != 4) return;
|
||||
String receiptRefBaseKey = String.valueOf(entry.getReceiptRefBaseKey() == null ? "" : entry.getReceiptRefBaseKey()).trim();
|
||||
if (receiptRefBaseKey.isEmpty()) return;
|
||||
long readAtMs = entry.getTimeMs();
|
||||
if (readAtMs <= 0) return;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE %s
|
||||
SET read_at_ms = CASE
|
||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||
WHEN read_at_ms > ? THEN ?
|
||||
ELSE read_at_ms
|
||||
END
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (1, 2)
|
||||
""".formatted(messagesTable()))) {
|
||||
ps.setLong(1, readAtMs);
|
||||
ps.setLong(2, readAtMs);
|
||||
ps.setLong(3, readAtMs);
|
||||
ps.setString(4, receiptRefBaseKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
FROM %s
|
||||
WHERE message_key = ?
|
||||
LIMIT 1
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new RevisionMarker(rs.getLong(1), rs.getLong(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
FROM %s
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (1, 2)
|
||||
ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC
|
||||
LIMIT 1
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, baseKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new RevisionMarker(rs.getLong(1), rs.getLong(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM %s
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (5, 6)
|
||||
LIMIT 1
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, baseKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException {
|
||||
Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin);
|
||||
return boundary != null && timeMs < boundary;
|
||||
}
|
||||
|
||||
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM %s
|
||||
WHERE message_type IN (7, 8)
|
||||
AND (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, fromLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SignedMessageEntry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM %s
|
||||
WHERE message_type IN (7, 8)
|
||||
AND (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
ORDER BY time_ms DESC
|
||||
LIMIT 1
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, fromLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM %s
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""".formatted(messagesTable()), baseKey, baseKey);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM %s
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""".formatted(messagesTable()))) {
|
||||
ps.setString(1, baseKey);
|
||||
ps.setString(2, baseKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM %s
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
""".formatted(messagesTable()), boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM %s
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
""".formatted(messagesTable()))) {
|
||||
ps.setLong(1, boundaryTimeMs);
|
||||
ps.setString(2, fromLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toLogin);
|
||||
ps.setString(5, fromLogin);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException {
|
||||
String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindObjects(ps, bindValues);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_message_session_delivery
|
||||
SET delivered = 0, delivered_at_ms = NULL
|
||||
WHERE message_key = ?
|
||||
""")) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private int insertStrict(Connection c, SignedMessageEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO %s (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void bindSignedMessage(PreparedStatement ps, SignedMessageEntry e) throws SQLException {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setLong(9, e.getRevisionTimeMs());
|
||||
ps.setLong(10, e.getReencryptedAtMs());
|
||||
ps.setBytes(11, e.getRawBlock());
|
||||
ps.setLong(12, e.getCreatedAtMs());
|
||||
ps.setString(13, e.getSourceApi());
|
||||
ps.setString(14, e.getOriginSessionId());
|
||||
ps.setString(15, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||
else ps.setInt(16, e.getReceiptRefType());
|
||||
if (e.getReadAtMs() == null) ps.setObject(17, null);
|
||||
else ps.setLong(17, e.getReadAtMs());
|
||||
}
|
||||
|
||||
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
||||
for (int i = 0; i < bindValues.length; i++) {
|
||||
Object value = bindValues[i];
|
||||
int param = i + 1;
|
||||
if (value instanceof String s) {
|
||||
ps.setString(param, s);
|
||||
} else if (value instanceof Long l) {
|
||||
ps.setLong(param, l);
|
||||
} else if (value instanceof Integer n) {
|
||||
ps.setInt(param, n);
|
||||
} else {
|
||||
ps.setObject(param, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConstraintViolation(SQLException ex) {
|
||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
}
|
||||
|
||||
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
|
||||
return work.run();
|
||||
}
|
||||
|
||||
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
||||
if (revisionCompare != 0) return revisionCompare;
|
||||
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
|
||||
}
|
||||
|
||||
private String messagesTable() {
|
||||
return "signed_messages";
|
||||
}
|
||||
|
||||
private SignedMessageEntry mapRow(ResultSet rs) throws Exception {
|
||||
SignedMessageEntry e = new SignedMessageEntry();
|
||||
e.setMessageKey(rs.getString("message_key"));
|
||||
e.setBaseKey(rs.getString("base_key"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setFromLogin(rs.getString("from_login"));
|
||||
e.setToLogin(rs.getString("to_login"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setNonce(rs.getLong("nonce"));
|
||||
e.setMessageType(rs.getInt("message_type"));
|
||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||
e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms"));
|
||||
e.setRawBlock(rs.getBytes("raw_block"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceApi(rs.getString("source_api"));
|
||||
e.setOriginSessionId(rs.getString("origin_session_id"));
|
||||
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
||||
int maybeRefType = rs.getInt("receipt_ref_type");
|
||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||
long maybeReadAt = rs.getLong("read_at_ms");
|
||||
e.setReadAtMs(rs.wasNull() ? null : maybeReadAt);
|
||||
return e;
|
||||
}
|
||||
|
||||
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
|
||||
private static RevisionMarker of(SignedMessageEntry entry) {
|
||||
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
||||
}
|
||||
}
|
||||
|
||||
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SqlWork<T> {
|
||||
T run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class SignedMessagesV2DAO {
|
||||
public enum ApplyStatus {
|
||||
APPLIED,
|
||||
DUPLICATE_OR_OLDER,
|
||||
BLOCKED_BY_MESSAGE_TOMBSTONE,
|
||||
BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
|
||||
public boolean applied() {
|
||||
return this == APPLIED;
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile SignedMessagesV2DAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SignedMessagesV2DAO() {}
|
||||
|
||||
public static SignedMessagesV2DAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedMessagesV2DAO.class) {
|
||||
if (instance == null) instance = new SignedMessagesV2DAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
int insertedFirst = insertStrict(c, first);
|
||||
int insertedSecond = insertStrict(c, second);
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
c.rollback();
|
||||
return false;
|
||||
} catch (SQLException sqlEx) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (isConstraintViolation(sqlEx)) {
|
||||
return false;
|
||||
}
|
||||
throw sqlEx;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
|
||||
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
upsertMessage(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
|
||||
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
FROM signed_messages_v2
|
||||
WHERE message_key = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getLatestConversationDelete(c, fromLogin, toLogin);
|
||||
}
|
||||
}
|
||||
|
||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String insertSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(insertSql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, deliveredAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String updateSql = """
|
||||
UPDATE signed_message_session_delivery
|
||||
SET delivered = 1, delivered_at_ms = ?
|
||||
WHERE message_key = ? AND session_id = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(updateSql)) {
|
||||
ps.setLong(1, deliveredAtMs);
|
||||
ps.setString(2, messageKey);
|
||||
ps.setString(3, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String fillSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
)
|
||||
SELECT m.message_key, ?, 0, NULL, ?
|
||||
FROM signed_messages_v2 m
|
||||
WHERE (
|
||||
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (5, 6, 7, 8)
|
||||
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||
)
|
||||
""";
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setLong(2, now);
|
||||
ps.setString(3, login);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, login);
|
||||
ps.setString(6, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||
m.receipt_ref_base_key, m.receipt_ref_type
|
||||
FROM signed_messages_v2 m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_key) DO UPDATE SET
|
||||
base_key = excluded.base_key,
|
||||
target_login = excluded.target_login,
|
||||
from_login = excluded.from_login,
|
||||
to_login = excluded.to_login,
|
||||
time_ms = excluded.time_ms,
|
||||
nonce = excluded.nonce,
|
||||
message_type = excluded.message_type,
|
||||
revision_time_ms = excluded.revision_time_ms,
|
||||
reencrypted_at_ms = excluded.reencrypted_at_ms,
|
||||
raw_block = excluded.raw_block,
|
||||
created_at_ms = excluded.created_at_ms,
|
||||
source_api = excluded.source_api,
|
||||
origin_session_id = excluded.origin_session_id,
|
||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||
receipt_ref_type = excluded.receipt_ref_type
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
FROM signed_messages_v2
|
||||
WHERE message_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new RevisionMarker(rs.getLong(1), rs.getLong(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
FROM signed_messages_v2
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (1, 2)
|
||||
ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, baseKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new RevisionMarker(rs.getLong(1), rs.getLong(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM signed_messages_v2
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (5, 6)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, baseKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException {
|
||||
Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin);
|
||||
return boundary != null && timeMs < boundary;
|
||||
}
|
||||
|
||||
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (7, 8)
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, fromLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (7, 8)
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
ORDER BY time_ms DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, fromLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""", baseKey, baseKey);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM signed_messages_v2
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""")) {
|
||||
ps.setString(1, baseKey);
|
||||
ps.setString(2, baseKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
""", boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM signed_messages_v2
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
""")) {
|
||||
ps.setLong(1, boundaryTimeMs);
|
||||
ps.setString(2, fromLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toLogin);
|
||||
ps.setString(5, fromLogin);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException {
|
||||
String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindObjects(ps, bindValues);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_message_session_delivery
|
||||
SET delivered = 0, delivered_at_ms = NULL
|
||||
WHERE message_key = ?
|
||||
""")) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setLong(9, e.getRevisionTimeMs());
|
||||
ps.setLong(10, e.getReencryptedAtMs());
|
||||
ps.setBytes(11, e.getRawBlock());
|
||||
ps.setLong(12, e.getCreatedAtMs());
|
||||
ps.setString(13, e.getSourceApi());
|
||||
ps.setString(14, e.getOriginSessionId());
|
||||
ps.setString(15, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||
else ps.setInt(16, e.getReceiptRefType());
|
||||
}
|
||||
|
||||
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
||||
for (int i = 0; i < bindValues.length; i++) {
|
||||
Object value = bindValues[i];
|
||||
int param = i + 1;
|
||||
if (value instanceof String s) {
|
||||
ps.setString(param, s);
|
||||
} else if (value instanceof Long l) {
|
||||
ps.setLong(param, l);
|
||||
} else if (value instanceof Integer n) {
|
||||
ps.setInt(param, n);
|
||||
} else {
|
||||
ps.setObject(param, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConstraintViolation(SQLException ex) {
|
||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
}
|
||||
|
||||
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
||||
if (revisionCompare != 0) return revisionCompare;
|
||||
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
|
||||
}
|
||||
|
||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||
e.setMessageKey(rs.getString("message_key"));
|
||||
e.setBaseKey(rs.getString("base_key"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setFromLogin(rs.getString("from_login"));
|
||||
e.setToLogin(rs.getString("to_login"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setNonce(rs.getLong("nonce"));
|
||||
e.setMessageType(rs.getInt("message_type"));
|
||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||
e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms"));
|
||||
e.setRawBlock(rs.getBytes("raw_block"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceApi(rs.getString("source_api"));
|
||||
e.setOriginSessionId(rs.getString("origin_session_id"));
|
||||
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
||||
int maybeRefType = rs.getInt("receipt_ref_type");
|
||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||
return e;
|
||||
}
|
||||
|
||||
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
|
||||
private static RevisionMarker of(SignedMessageV2Entry entry) {
|
||||
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.KeyEncodingUtil;
|
||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
|
||||
*/
|
||||
public final class SolanaUserPdaCurrentDAO {
|
||||
|
||||
private static volatile SolanaUserPdaCurrentDAO instance;
|
||||
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SolanaUserPdaCurrentDAO() {}
|
||||
|
||||
public static SolanaUserPdaCurrentDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SolanaUserPdaCurrentDAO.class) {
|
||||
if (instance == null) {
|
||||
instance = new SolanaUserPdaCurrentDAO();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public SolanaUserPdaCurrentEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByBlockchainName(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
blockchain_name,
|
||||
blockchain_key,
|
||||
paid_limit_bytes
|
||||
FROM solana_user_pda_current
|
||||
WHERE blockchain_name = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
return null;
|
||||
}
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
||||
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
||||
entry.setLogin(rs.getString("login"));
|
||||
entry.setBlockchainName(rs.getString("blockchain_name"));
|
||||
entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
|
||||
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
@@ -13,7 +13,7 @@ import java.util.List;
|
||||
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
||||
* - login цели (channelLogin)
|
||||
* - blockchainName цели (channelBchName)
|
||||
* - count публикаций (TEXT_POST)
|
||||
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||
* - last publication: bytes оригинального блока (для timestamp)
|
||||
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
||||
*
|
||||
@@ -25,7 +25,7 @@ import java.util.List;
|
||||
public final class SubscriptionsDAO {
|
||||
|
||||
private static volatile SubscriptionsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SubscriptionsDAO() {}
|
||||
|
||||
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
|
||||
|
||||
/**
|
||||
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
||||
* - count публикаций (TEXT_POST)
|
||||
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
||||
*
|
||||
* Поведение при 0 публикаций:
|
||||
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub AS (
|
||||
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub_block AS (
|
||||
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
|
||||
|
||||
// last_pub
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -17,7 +17,7 @@ import java.util.List;
|
||||
public final class SyncServersDAO {
|
||||
|
||||
private static volatile SyncServersDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SyncServersDAO() {}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.TestFreeAvatarUploadEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -11,7 +11,7 @@ import java.sql.SQLException;
|
||||
public final class TestFreeAvatarUploadsDAO {
|
||||
|
||||
private static volatile TestFreeAvatarUploadsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private TestFreeAvatarUploadsDAO() {
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public final class TestFreeAvatarUploadsDAO {
|
||||
String sql = """
|
||||
SELECT login, used_count, updated_at_ms, last_tx_id
|
||||
FROM test_free_avatar_uploads
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.KeyEncodingUtil;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO локальной routing-проекции access servers пользователей.
|
||||
*/
|
||||
public final class UserAccessServersCurrentDAO {
|
||||
|
||||
private static volatile UserAccessServersCurrentDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserAccessServersCurrentDAO() {}
|
||||
|
||||
public static UserAccessServersCurrentDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserAccessServersCurrentDAO.class) {
|
||||
if (instance == null) instance = new UserAccessServersCurrentDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public List<UserAccessServerRouteEntry> listByUserLogin(String userLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return listByUserLogin(c, userLogin);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT user_login, server_login, server_url, server_client_key
|
||||
FROM user_access_servers_current
|
||||
WHERE LOWER(user_login) = LOWER(?)
|
||||
ORDER BY server_login
|
||||
""";
|
||||
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, userLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT user_login
|
||||
FROM user_access_servers_current
|
||||
WHERE LOWER(server_login) = LOWER(?)
|
||||
ORDER BY user_login
|
||||
""";
|
||||
List<String> result = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, serverLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(rs.getString("user_login"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||
entry.setUserLogin(rs.getString("user_login"));
|
||||
entry.setServerLogin(rs.getString("server_login"));
|
||||
entry.setServerUrl(rs.getString("server_url"));
|
||||
entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key")));
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* UserCreateDAO — атомарное добавление пользователя:
|
||||
* - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key)
|
||||
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - только INSERT (без перезаписи существующих записей)
|
||||
* - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято)
|
||||
*/
|
||||
public final class UserCreateDAO {
|
||||
|
||||
private static volatile UserCreateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
|
||||
|
||||
private UserCreateDAO() {}
|
||||
|
||||
public static UserCreateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserCreateDAO.class) {
|
||||
if (instance == null) instance = new UserCreateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true если добавили; false если занято (login уже есть или blockchainName уже существует).
|
||||
*/
|
||||
public boolean insertUserWithBlockchain(
|
||||
String login,
|
||||
String blockchainName,
|
||||
String solanaKey,
|
||||
String blockchainKey,
|
||||
String clientKey,
|
||||
long sizeLimit,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
// 1) solana_users
|
||||
SolanaUserEntry u = new SolanaUserEntry();
|
||||
u.setLogin(login);
|
||||
u.setBlockchainName(blockchainName);
|
||||
u.setSolanaKey(solanaKey);
|
||||
u.setBlockchainKey(blockchainKey);
|
||||
u.setClientKey(clientKey);
|
||||
|
||||
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint
|
||||
|
||||
// 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку)
|
||||
insertBlockchainStateStrict(
|
||||
c,
|
||||
blockchainName,
|
||||
login,
|
||||
blockchainKey,
|
||||
sizeLimit,
|
||||
nowMs
|
||||
);
|
||||
|
||||
c.commit();
|
||||
return true;
|
||||
|
||||
} catch (SQLException e) {
|
||||
c.rollback();
|
||||
|
||||
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
|
||||
if (msg.contains("constraint")) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void insertBlockchainStateStrict(
|
||||
Connection c,
|
||||
String blockchainName,
|
||||
String login,
|
||||
String blockchainKey,
|
||||
long sizeLimit,
|
||||
long nowMs
|
||||
) 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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setString(i++, login);
|
||||
ps.setString(i++, blockchainKey);
|
||||
|
||||
ps.setLong(i++, sizeLimit);
|
||||
ps.setLong(i++, 0L);
|
||||
|
||||
ps.setInt(i++, -1);
|
||||
ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет
|
||||
ps.setLong(i++, nowMs);
|
||||
|
||||
ps.executeUpdate(); // если blockchainName занят -> constraint (PK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class UserNotificationsStateDAO {
|
||||
private static volatile UserNotificationsStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserNotificationsStateDAO() {}
|
||||
|
||||
public static UserNotificationsStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserNotificationsStateDAO.class) {
|
||||
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_notifications_state (
|
||||
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||
created_at_ms = EXCLUDED.created_at_ms,
|
||||
source_login = EXCLUDED.source_login,
|
||||
target_login = EXCLUDED.target_login,
|
||||
target_bch_name = EXCLUDED.target_bch_name,
|
||||
target_block_number = EXCLUDED.target_block_number,
|
||||
target_block_hash = EXCLUDED.target_block_hash,
|
||||
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||
source_text = EXCLUDED.source_text
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, e.getOwnerLogin());
|
||||
ps.setString(i++, e.getNotificationKind());
|
||||
ps.setLong(i++, e.getCreatedAtMs());
|
||||
ps.setString(i++, e.getSourceLogin());
|
||||
ps.setString(i++, e.getSourceBchName());
|
||||
ps.setInt(i++, e.getSourceBlockNumber());
|
||||
ps.setBytes(i++, e.getSourceBlockHash());
|
||||
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||
ps.setInt(i++, e.getSourceMsgSubType());
|
||||
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, kind);
|
||||
ps.setInt(3, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setNotificationKind(rs.getString("notification_kind"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceLogin(rs.getString("source_login"));
|
||||
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||
e.setSourceText(rs.getString("source_text"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
|
||||
import java.sql.*;
|
||||
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
public final class UserParamsDAO {
|
||||
|
||||
private static volatile UserParamsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserParamsDAO() { }
|
||||
|
||||
@@ -89,7 +89,7 @@ public final class UserParamsDAO {
|
||||
client_key,
|
||||
signature
|
||||
FROM users_params
|
||||
WHERE login = ? COLLATE NOCASE AND param = ?
|
||||
WHERE LOWER(login) = LOWER(?) AND param = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
@@ -120,7 +120,7 @@ public final class UserParamsDAO {
|
||||
client_key,
|
||||
signature
|
||||
FROM users_params
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
ORDER BY time_ms DESC
|
||||
""";
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserSettingsDAO — хранение пользовательских настроек.
|
||||
*
|
||||
* Правило:
|
||||
* - уникальность: login + setting_type + setting_key
|
||||
* - запись обновляется только если time_ms новее
|
||||
* - synced=true означает, что значение уже дошло до второго сервера
|
||||
*/
|
||||
public final class UserSettingsDAO {
|
||||
|
||||
private static volatile UserSettingsDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserSettingsDAO() {}
|
||||
|
||||
public static UserSettingsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserSettingsDAO.class) {
|
||||
if (instance == null) instance = new UserSettingsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_settings (
|
||||
login, setting_type, setting_key, time_ms,
|
||||
value_text, value_num, client_key, signature, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (login, setting_type, setting_key)
|
||||
DO UPDATE SET
|
||||
time_ms = EXCLUDED.time_ms,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_num = EXCLUDED.value_num,
|
||||
client_key = EXCLUDED.client_key,
|
||||
signature = EXCLUDED.signature,
|
||||
synced = EXCLUDED.synced
|
||||
WHERE user_settings.time_ms < EXCLUDED.time_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setInt(2, e.getSettingType());
|
||||
ps.setString(3, e.getSettingKey());
|
||||
ps.setLong(4, e.getTimeMs());
|
||||
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
|
||||
ps.setLong(6, e.getValueNum());
|
||||
|
||||
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
|
||||
else ps.setString(7, e.getClientKey());
|
||||
|
||||
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
|
||||
else ps.setString(8, e.getSignature());
|
||||
|
||||
ps.setBoolean(9, e.isSynced());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserSettingEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return upsertIfNewer(c, e);
|
||||
}
|
||||
}
|
||||
|
||||
public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, settingType);
|
||||
ps.setString(3, settingKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLoginTypeKey(c, login, settingType, settingKey);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND (
|
||||
time_ms > ?
|
||||
OR (time_ms = ? AND setting_key > ?)
|
||||
)
|
||||
ORDER BY time_ms ASC, setting_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setLong(2, Math.max(0L, afterTimeMs));
|
||||
ps.setLong(3, Math.max(0L, afterTimeMs));
|
||||
ps.setString(4, afterSettingKey == null ? "" : afterSettingKey);
|
||||
ps.setInt(5, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND synced = FALSE
|
||||
ORDER BY time_ms ASC, setting_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE user_settings
|
||||
SET synced = TRUE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, settingType);
|
||||
ps.setString(3, settingKey);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE user_settings
|
||||
SET synced = FALSE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced() throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return markAllUnsynced(c);
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced(Connection c) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserSettingEntry e = new UserSettingEntry();
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSettingType(rs.getInt("setting_type"));
|
||||
e.setSettingKey(rs.getString("setting_key"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setValueText(rs.getString("value_text"));
|
||||
e.setValueNum(rs.getLong("value_num"));
|
||||
|
||||
String clientKey = rs.getString("client_key");
|
||||
if (rs.wasNull()) clientKey = null;
|
||||
e.setClientKey(clientKey);
|
||||
|
||||
String signature = rs.getString("signature");
|
||||
if (rs.wasNull()) signature = null;
|
||||
e.setSignature(signature);
|
||||
|
||||
e.setSynced(rs.getBoolean("synced"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserSettingsSyncPeerStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public final class UserSettingsSyncPeerStateDAO {
|
||||
|
||||
private static volatile UserSettingsSyncPeerStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserSettingsSyncPeerStateDAO() {}
|
||||
|
||||
public static UserSettingsSyncPeerStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserSettingsSyncPeerStateDAO.class) {
|
||||
if (instance == null) instance = new UserSettingsSyncPeerStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin);
|
||||
if (existing != null) return existing;
|
||||
long nowMs = System.currentTimeMillis();
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setLong(4, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return get(c, ownerLogin, remoteServerLogin);
|
||||
}
|
||||
|
||||
public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key,
|
||||
bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms
|
||||
FROM user_settings_sync_peer_state
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
cursor_time_ms = EXCLUDED.cursor_time_ms,
|
||||
cursor_setting_key = EXCLUDED.cursor_setting_key,
|
||||
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||
last_error = NULL,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setLong(4, Math.max(0L, cursorTimeMs));
|
||||
ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey);
|
||||
ps.setBoolean(6, bootstrapCompleted);
|
||||
ps.setLong(7, nowMs);
|
||||
ps.setLong(8, nowMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setString(4, error);
|
||||
ps.setLong(5, nowMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false);
|
||||
}
|
||||
|
||||
public int deleteAllForOwner(String ownerLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||
e.setCursorTimeMs(rs.getLong("cursor_time_ms"));
|
||||
e.setCursorSettingKey(rs.getString("cursor_setting_key"));
|
||||
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||
long lastSyncAtMs = rs.getLong("last_sync_at_ms");
|
||||
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
|
||||
private int channelRootBlockNumber;
|
||||
private byte[] channelRootBlockHash;
|
||||
private long createdAtMs;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private long avaSize;
|
||||
private long metaUpdatedAtMs;
|
||||
|
||||
public String getSlug() {
|
||||
return slug;
|
||||
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
|
||||
public void setCreatedAtMs(long createdAtMs) {
|
||||
this.createdAtMs = createdAtMs;
|
||||
}
|
||||
|
||||
public String getAvaAr() {
|
||||
return avaAr;
|
||||
}
|
||||
|
||||
public void setAvaAr(String avaAr) {
|
||||
this.avaAr = avaAr;
|
||||
}
|
||||
|
||||
public String getAvaSha256() {
|
||||
return avaSha256;
|
||||
}
|
||||
|
||||
public void setAvaSha256(String avaSha256) {
|
||||
this.avaSha256 = avaSha256;
|
||||
}
|
||||
|
||||
public long getAvaSize() {
|
||||
return avaSize;
|
||||
}
|
||||
|
||||
public void setAvaSize(long avaSize) {
|
||||
this.avaSize = avaSize;
|
||||
}
|
||||
|
||||
public long getMetaUpdatedAtMs() {
|
||||
return metaUpdatedAtMs;
|
||||
}
|
||||
|
||||
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
|
||||
this.metaUpdatedAtMs = metaUpdatedAtMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ package shine.db.entities;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* SolanaUserEntry — локальная запись пользователя из Solana.
|
||||
* CurrentUserEntry — локальная runtime-проекция текущего состояния пользователя.
|
||||
*
|
||||
* Таблица: solana_users
|
||||
* Источник:
|
||||
* - solana_user_pda_current
|
||||
*
|
||||
* Поля:
|
||||
* - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE)
|
||||
* - login — PRIMARY KEY (TEXT)
|
||||
* - blockchain_name — TEXT NOT NULL
|
||||
* - solana_key — TEXT NOT NULL
|
||||
* - blockchain_key — TEXT NOT NULL
|
||||
* - client_key — TEXT NOT NULL
|
||||
*/
|
||||
public class SolanaUserEntry {
|
||||
public class CurrentUserEntry {
|
||||
|
||||
private String login;
|
||||
|
||||
@@ -29,9 +30,9 @@ public class SolanaUserEntry {
|
||||
/** Ключ устройства (публичный ключ устройства) */
|
||||
private String clientKey;
|
||||
|
||||
public SolanaUserEntry() {}
|
||||
public CurrentUserEntry() {}
|
||||
|
||||
public SolanaUserEntry(String login,
|
||||
public CurrentUserEntry(String login,
|
||||
String blockchainName,
|
||||
String solanaKey,
|
||||
String blockchainKey,
|
||||
@@ -81,4 +82,4 @@ public class SolanaUserEntry {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DirectMessageEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmSyncPeerStateEntry {
|
||||
private String ownerLogin;
|
||||
private String remoteServerLogin;
|
||||
private String remoteServerUrl;
|
||||
private long cursorStoredAtMs;
|
||||
private String cursorMessageKey;
|
||||
private boolean bootstrapCompleted;
|
||||
private Long lastSyncAtMs;
|
||||
private String lastError;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
|
||||
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
|
||||
public String getCursorMessageKey() { return cursorMessageKey; }
|
||||
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
|
||||
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedDirectMessageHistoryEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private int targetMode;
|
||||
private String targetSessionId;
|
||||
private int messageType;
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private byte[] rawPacket;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public int getTargetMode() { return targetMode; }
|
||||
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
|
||||
public String getTargetSessionId() { return targetSessionId; }
|
||||
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getNonce() { return nonce; }
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public byte[] getRawPacket() { return rawPacket; }
|
||||
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedMessageV2Entry {
|
||||
public class SignedMessageEntry {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
private String targetLogin;
|
||||
@@ -17,6 +17,7 @@ public class SignedMessageV2Entry {
|
||||
private String originSessionId;
|
||||
private String receiptRefBaseKey;
|
||||
private Integer receiptRefType;
|
||||
private Long readAtMs;
|
||||
|
||||
public String getMessageKey() { return messageKey; }
|
||||
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||
@@ -50,4 +51,6 @@ public class SignedMessageV2Entry {
|
||||
public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
|
||||
public Integer getReceiptRefType() { return receiptRefType; }
|
||||
public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; }
|
||||
public Long getReadAtMs() { return readAtMs; }
|
||||
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Минимальный срез текущей записи пользователя из PostgreSQL sync-таблицы.
|
||||
*
|
||||
* Источник: solana_user_pda_current
|
||||
*/
|
||||
public final class SolanaUserPdaCurrentEntry {
|
||||
|
||||
private String login;
|
||||
private String blockchainName;
|
||||
private String blockchainKey;
|
||||
private long paidLimitBytes;
|
||||
|
||||
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 getBlockchainKey() {
|
||||
return blockchainKey;
|
||||
}
|
||||
|
||||
public void setBlockchainKey(String blockchainKey) {
|
||||
this.blockchainKey = blockchainKey;
|
||||
}
|
||||
|
||||
public long getPaidLimitBytes() {
|
||||
return paidLimitBytes;
|
||||
}
|
||||
|
||||
public void setPaidLimitBytes(long paidLimitBytes) {
|
||||
this.paidLimitBytes = paidLimitBytes;
|
||||
}
|
||||
}
|
||||