SHA256
Compare commits
210
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
1be1d56599 | ||
|
|
2df0a78eb2 | ||
|
|
c3253dceac | ||
|
|
ef068eca81 | ||
|
|
f9ae811702 | ||
|
|
ea0098d705 | ||
|
|
4991a146d5 | ||
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 | ||
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 | ||
|
|
e0295eebde | ||
|
|
ebc9143593 | ||
|
|
e7c8fd748c | ||
|
|
aff601f61a | ||
|
|
0c5089fa79 | ||
|
|
e5556f3706 | ||
|
|
09c7f8541e | ||
|
|
8eb2ef438c | ||
|
|
34abe14151 | ||
|
|
90e37c198f | ||
|
|
1373283947 | ||
|
|
065616a18b | ||
|
|
53cd55a2a0 | ||
|
|
079be37fff | ||
|
|
806a8c57d4 | ||
|
|
797e769cc3 | ||
|
|
822a72e246 | ||
|
|
58b564b385 | ||
|
|
7aa9e2f6a4 | ||
|
|
f645686d87 | ||
|
|
6fcaa5d3d4 | ||
|
|
5d19e17a0b | ||
|
|
52a9f1ac1d | ||
|
|
3dec5e3225 | ||
|
|
90ded1612a | ||
|
|
7ab9fb7c28 | ||
|
|
6f499c68e2 | ||
|
|
19beba8fd7 | ||
|
|
f7e33802c8 | ||
|
|
c03d94105c | ||
|
|
632d56737b | ||
|
|
8801b93973 | ||
|
|
390a2b988e | ||
|
|
a66d7a64e6 | ||
|
|
a8596d3350 | ||
|
|
8a5dabee95 | ||
|
|
da859468d3 | ||
|
|
3e4583bbe9 | ||
|
|
b47440dd72 | ||
|
|
357a05f7ec | ||
|
|
f2fdb8195c | ||
|
|
8a0275d962 | ||
|
|
ef1fd9f579 | ||
|
|
f6b95d94bc | ||
|
|
2a65e6d359 | ||
|
|
83f87ba22c | ||
|
|
01321f5200 | ||
|
|
3a5851939e | ||
|
|
7d929e626e | ||
|
|
d8e0c77951 | ||
|
|
78ddb8ffb6 | ||
|
|
db37f35d09 | ||
|
|
d8f5fe4fd0 | ||
|
|
015fade4c0 | ||
|
|
da14b51e80 | ||
|
|
7a5ac01d1c | ||
|
|
5e6d64e965 | ||
|
|
c425fa41aa | ||
|
|
781157299f | ||
|
|
0f30317bb4 | ||
|
|
1f1bc0a7b9 | ||
|
|
60c8a6608a | ||
|
|
86adaf8c6b | ||
|
|
731688d16e | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b | ||
|
|
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 | ||
|
|
809c216a5c | ||
|
|
415a40e436 | ||
|
|
d233f1d73f |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
@@ -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/` — конфигурация сервера
|
||||
@@ -23,7 +23,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
||||
|
||||
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
||||
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
||||
с которыми синхронизируются блоки и DM;
|
||||
с которыми синхронизируются пользовательские блокчейны;
|
||||
- **корневой ключ** сервера (`root_key`).
|
||||
|
||||
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
||||
@@ -49,7 +49,9 @@ shine-UI/server-ui.html
|
||||
|
||||
## Синхронизация с партнёрскими серверами
|
||||
|
||||
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
|
||||
Сервер должен синхронизировать блоки пользовательских блокчейнов с
|
||||
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
|
||||
они доставляются только на первый access-сервер получателя.
|
||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
implementation project(':shine-server-config')
|
||||
implementation project(':shine-server-db')
|
||||
implementation project(':shine-server-crypto')
|
||||
implementation project(':shine-server-blockchain')
|
||||
implementation project(':shine-server-net-protocol')
|
||||
implementation project(':shine-server-solana-users-sync')
|
||||
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||
}
|
||||
|
||||
test { useJUnitPlatform() }
|
||||
@@ -0,0 +1,56 @@
|
||||
package server.archive;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Единые локальные имена больших архивных файлов. */
|
||||
public final class ArchiveFileNames {
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
|
||||
|
||||
private ArchiveFileNames() {}
|
||||
|
||||
/**
|
||||
* Временное имя полностью собранного файла до появления Arweave TX ID.
|
||||
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
|
||||
*/
|
||||
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Постоянное имя после успешной загрузки в Arweave.
|
||||
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
|
||||
*/
|
||||
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
|
||||
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
|
||||
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
|
||||
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
|
||||
}
|
||||
|
||||
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
|
||||
}
|
||||
|
||||
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
|
||||
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
|
||||
}
|
||||
|
||||
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
|
||||
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
|
||||
}
|
||||
String safeLogin = sanitize(login);
|
||||
String number = String.format("%05d", bigBlockNumber);
|
||||
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
|
||||
return safeLogin + "." + number + "." + date;
|
||||
}
|
||||
|
||||
static String sanitize(String login) {
|
||||
String s = login == null ? "server" : login.trim();
|
||||
if (s.isEmpty()) s = "server";
|
||||
return s.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
|
||||
public record ArchiveImportConfig(
|
||||
Set<String> allowedPublishers,
|
||||
int intervalMinutes,
|
||||
String arweaveGateway,
|
||||
Path workDir
|
||||
) {
|
||||
public static ArchiveImportConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
Set<String> approved = new LinkedHashSet<>();
|
||||
String raw = c.getParam("archive.import.allowedPublishers");
|
||||
if (raw != null) {
|
||||
for (String item : raw.split(",")) {
|
||||
String login = item.trim().toLowerCase(Locale.ROOT);
|
||||
if (!login.isEmpty()) approved.add(login);
|
||||
}
|
||||
}
|
||||
String gateway = c.getParam("archive.arweave.gateway");
|
||||
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
|
||||
String dir = c.getParam("archive.import.workDir");
|
||||
if (dir == null || dir.isBlank()) dir = "data/archive-import";
|
||||
return new ArchiveImportConfig(
|
||||
Set.copyOf(approved),
|
||||
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
|
||||
gateway.trim(),
|
||||
Path.of(dir));
|
||||
}
|
||||
|
||||
public boolean enabled() { return !allowedPublishers.isEmpty(); }
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
|
||||
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
|
||||
* затем по умолчанию раз в час.
|
||||
*/
|
||||
public final class ArchiveImportScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArchiveImportScheduler() {}
|
||||
|
||||
public static synchronized void startOrLog() {
|
||||
if (executor != null) return;
|
||||
ArchiveImportConfig cfg = ArchiveImportConfig.load();
|
||||
if (!cfg.enabled()) {
|
||||
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ArchiveImportService service = new ArchiveImportService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "archive-importer");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
executor.scheduleWithFixedDelay(
|
||||
() -> {
|
||||
try { service.pollOnce(); }
|
||||
catch (Exception e) { log.error("Archive importer cycle failed", e); }
|
||||
},
|
||||
10,
|
||||
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
|
||||
TimeUnit.SECONDS);
|
||||
log.info("Archive importer включён. approvedPublishers={} interval={}min",
|
||||
cfg.allowedPublishers(), cfg.intervalMinutes());
|
||||
} catch (Exception e) {
|
||||
log.error("Archive importer не запущен", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void close() {
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
executor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import shine.db.archive.*;
|
||||
import shine.db.dao.*;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/** Импорт archive head только от серверов из server-side whitelist. */
|
||||
public final class ArchiveImportService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
|
||||
private final ArchiveImportConfig cfg;
|
||||
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
|
||||
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
|
||||
private final ShineArchiveReader reader = new ShineArchiveReader();
|
||||
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
Files.createDirectories(cfg.workDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* Никаких отдельных Solana RPC запросов здесь нет.
|
||||
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
|
||||
*/
|
||||
public void pollOnce() throws Exception {
|
||||
if (!cfg.enabled()) return;
|
||||
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
|
||||
try {
|
||||
importHead(head);
|
||||
} catch (Exception e) {
|
||||
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void importHead(ArchivePublisherHead publisher) throws Exception {
|
||||
String headTxId = publisher.archiveHeadTxId();
|
||||
byte[] headHash = hex(publisher.archiveHeadHash());
|
||||
|
||||
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
|
||||
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
|
||||
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
|
||||
if (!dao.markHeadImported(publisher.login(), headTxId)) {
|
||||
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
|
||||
publisher.login(), headTxId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Path headFile = download(headTxId);
|
||||
ShineArchiveReader.Document head;
|
||||
try {
|
||||
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
|
||||
} finally {
|
||||
Files.deleteIfExists(headFile);
|
||||
}
|
||||
|
||||
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
|
||||
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
|
||||
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
|
||||
|
||||
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
|
||||
for (int i = startIndex; i < previous.size(); i++) {
|
||||
ShineArchiveReader.Reference ref = previous.get(i);
|
||||
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
|
||||
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
|
||||
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
|
||||
}
|
||||
}
|
||||
|
||||
// Head уже скачан и проверен выше — второй раз его из Arweave не качаем.
|
||||
importDocument(publisher, headTxId, head);
|
||||
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
|
||||
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
|
||||
}
|
||||
if (!dao.markHeadImported(publisher.login(), headTxId)) {
|
||||
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
|
||||
}
|
||||
|
||||
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
|
||||
publisher.login(), head.bigBlockNumber(), headTxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает индекс первого ещё не импортированного previous big block.
|
||||
* Пустой cursor = новый сервер, импортируем всю FULL историю.
|
||||
*/
|
||||
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
|
||||
String lastImportedTxId,
|
||||
String currentHeadTxId) {
|
||||
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
|
||||
if (last.isEmpty()) return 0;
|
||||
if (last.equals(currentHeadTxId)) return previous.size();
|
||||
for (int i = 0; i < previous.size(); i++) {
|
||||
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
|
||||
}
|
||||
|
||||
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
|
||||
Path file = download(txId);
|
||||
try {
|
||||
ShineArchiveReader.Document doc = reader.readAndVerify(
|
||||
file, publisher.login(), expectedHash, publisher.rootKey());
|
||||
importDocument(publisher, txId, doc);
|
||||
} finally {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
|
||||
private void importDocument(ArchivePublisherHead publisher, String txId,
|
||||
ShineArchiveReader.Document doc) throws Exception {
|
||||
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
|
||||
processChunk(publisher.login(), txId, doc, chunk);
|
||||
}
|
||||
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
|
||||
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
|
||||
}
|
||||
|
||||
private void processChunk(String publisherLogin, String txId,
|
||||
ShineArchiveReader.Document doc,
|
||||
ShineArchiveReader.Chunk chunk) throws Exception {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
|
||||
lock.lock();
|
||||
try {
|
||||
ensureState(chunk.blockchainName());
|
||||
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
|
||||
int localLast = state == null ? -1 : state.getLastBlockNumber();
|
||||
long archiveLast = -1;
|
||||
|
||||
for (byte[] raw : chunk.rawRecords()) {
|
||||
BchBlockEntry block = new BchBlockEntry(raw);
|
||||
archiveLast = Integer.toUnsignedLong(block.blockNumber);
|
||||
|
||||
if (block.blockNumber <= localLast) {
|
||||
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
|
||||
if (!Arrays.equals(localHash, block.getHash32())) {
|
||||
throw new IllegalStateException(
|
||||
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.blockNumber != localLast + 1) {
|
||||
throw new IllegalStateException(
|
||||
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
|
||||
" next=" + block.blockNumber);
|
||||
}
|
||||
|
||||
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
|
||||
if (!result.ok()) {
|
||||
throw new IllegalStateException(
|
||||
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
|
||||
block.blockNumber + ": " + result.reasonCode());
|
||||
}
|
||||
localLast = block.blockNumber;
|
||||
}
|
||||
|
||||
if (archiveLast >= 0) {
|
||||
dao.upsertLocation(new ArchiveBlockchainLocation(
|
||||
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
|
||||
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureState(String blockchainName) throws Exception {
|
||||
if (stateDao.getByBlockchainName(blockchainName) != null) return;
|
||||
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
|
||||
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
|
||||
|
||||
BlockchainStateEntry s = new BlockchainStateEntry();
|
||||
s.setBlockchainName(id.blockchainName());
|
||||
s.setLogin(id.login());
|
||||
s.setBlockchainKey(id.blockchainKey());
|
||||
s.setSizeLimit(id.sizeLimit());
|
||||
s.setFileSizeBytes(0);
|
||||
s.setLastBlockNumber(-1);
|
||||
s.setLastBlockHash(null);
|
||||
s.setUpdatedAtMs(System.currentTimeMillis());
|
||||
stateDao.insertIfMissing(s);
|
||||
}
|
||||
|
||||
private Path download(String txId) throws Exception {
|
||||
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
|
||||
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
|
||||
.timeout(Duration.ofMinutes(10))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
Files.deleteIfExists(target);
|
||||
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static byte[] hex(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value).trim();
|
||||
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
|
||||
byte[] out = new byte[32];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import sync.util.Base58Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
|
||||
final class ArchiveKeyLoader {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private ArchiveKeyLoader() {}
|
||||
|
||||
static byte[] loadSeed32(Path path) throws IOException {
|
||||
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
|
||||
byte[] raw = Files.readAllBytes(path);
|
||||
if (raw.length == 32) return raw.clone();
|
||||
if (raw.length == 64) return first32(raw);
|
||||
|
||||
String text = new String(raw, StandardCharsets.UTF_8).trim();
|
||||
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
|
||||
|
||||
// Solana keypair JSON: [64 чисел].
|
||||
if (text.startsWith("[")) {
|
||||
JsonNode arr = JSON.readTree(text);
|
||||
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
|
||||
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
|
||||
}
|
||||
byte[] bytes = new byte[arr.size()];
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
int v = arr.get(i).asInt(-1);
|
||||
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
|
||||
bytes[i] = (byte) v;
|
||||
}
|
||||
return bytes.length == 32 ? bytes : first32(bytes);
|
||||
}
|
||||
|
||||
// Base58 seed32 или Solana secret key 64 bytes.
|
||||
try {
|
||||
byte[] decoded = Base58Util.decode(text);
|
||||
if (decoded.length == 32) return decoded;
|
||||
if (decoded.length == 64) return first32(decoded);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Пробуем остальные текстовые форматы ниже.
|
||||
}
|
||||
|
||||
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
|
||||
if (decoded.length == 32) return decoded;
|
||||
if (decoded.length == 64) return first32(decoded);
|
||||
if (decoded.length > 32) {
|
||||
byte[] seed = new byte[32];
|
||||
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
|
||||
return seed;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Ниже выдадим понятную общую ошибку.
|
||||
}
|
||||
|
||||
throw new IOException("Не удалось распознать Ed25519 key file: " + path
|
||||
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
|
||||
}
|
||||
|
||||
private static byte[] first32(byte[] bytes) {
|
||||
byte[] out = new byte[32];
|
||||
System.arraycopy(bytes, 0, out, 0, 32);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
import utils.config.SolanaProgramsConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
|
||||
public record ArchivePublisherConfig(
|
||||
boolean enabled,
|
||||
LocalTime publishTime,
|
||||
ZoneId publishZone,
|
||||
long maxFileBytes,
|
||||
Path workDir,
|
||||
String serverLogin,
|
||||
String arweaveGateway,
|
||||
Path arweaveWalletJwkPath,
|
||||
int arweaveMinConfirmations,
|
||||
int arweaveConfirmPollSeconds,
|
||||
int arweaveConfirmTimeoutMinutes,
|
||||
Path solanaRootKeyPath,
|
||||
Path solanaClientKeyPath,
|
||||
String solanaRpcUrl,
|
||||
String solanaUsersProgramId,
|
||||
String solanaPaymentsProgramId,
|
||||
int solanaConfirmPollSeconds,
|
||||
int solanaConfirmTimeoutMinutes,
|
||||
String solanaCommitment
|
||||
) {
|
||||
public static ArchivePublisherConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
return new ArchivePublisherConfig(
|
||||
c.getBoolean("archive.publish.enabled", false),
|
||||
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
|
||||
parseZone(c.getParam("archive.publish.zoneId")),
|
||||
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
|
||||
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
|
||||
requiredIfEnabled(c, "server.SHiNE.login"),
|
||||
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
|
||||
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
|
||||
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
|
||||
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
|
||||
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
|
||||
optionalPath(c.getParam("archive.solana.rootKeyPath")),
|
||||
optionalPath(c.getParam("archive.solana.clientKeyPath")),
|
||||
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
|
||||
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
|
||||
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
|
||||
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
|
||||
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
|
||||
orDefault(c.getParam("archive.solana.commitment"), "finalized")
|
||||
);
|
||||
}
|
||||
|
||||
public void validateForStart() {
|
||||
if (!enabled) return;
|
||||
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
|
||||
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
|
||||
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
|
||||
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
|
||||
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
|
||||
if (maxFileBytes >= 0x1_0000_0000L) {
|
||||
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requiredIfEnabled(AppConfig c, String name) {
|
||||
String v = c.getParam(name);
|
||||
return v == null ? "" : v.trim();
|
||||
}
|
||||
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
|
||||
private static String firstNonBlank(String... values) {
|
||||
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
|
||||
return "";
|
||||
}
|
||||
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
|
||||
private static LocalTime parseTime(String value) {
|
||||
try {
|
||||
return LocalTime.parse(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
|
||||
}
|
||||
}
|
||||
private static ZoneId parseZone(String value) {
|
||||
if (value == null || value.isBlank()) return ZoneId.systemDefault();
|
||||
try {
|
||||
return ZoneId.of(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
|
||||
}
|
||||
}
|
||||
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
|
||||
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
|
||||
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
|
||||
public final class ArchivePublisherScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArchivePublisherScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArchivePublisherConfig cfg;
|
||||
try {
|
||||
cfg = ArchivePublisherConfig.load();
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать archive publisher config", e);
|
||||
return;
|
||||
}
|
||||
if (!cfg.enabled()) {
|
||||
log.info("Archive publisher выключен (archive.publish.enabled=false)");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) return;
|
||||
|
||||
try {
|
||||
ArchivePublisherService service = new ArchivePublisherService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r, "shine-archive-publisher");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
|
||||
// создаём только в очередное назначенное суточное время.
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
service.resumeUnfinishedJob();
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось продолжить незавершённый archive job", e);
|
||||
}
|
||||
});
|
||||
scheduleNextDaily(service, cfg);
|
||||
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
|
||||
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
|
||||
} catch (Exception e) {
|
||||
STARTED.set(false);
|
||||
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
|
||||
ScheduledExecutorService e = executor;
|
||||
if (e == null || e.isShutdown()) return;
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
|
||||
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
|
||||
if (!next.isAfter(now)) next = next.plusDays(1);
|
||||
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
|
||||
|
||||
e.schedule(() -> {
|
||||
try {
|
||||
service.runCycle();
|
||||
} catch (Exception ex) {
|
||||
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
|
||||
} finally {
|
||||
scheduleNextDaily(service, cfg);
|
||||
}
|
||||
}, delayMs, TimeUnit.MILLISECONDS);
|
||||
|
||||
log.info("Следующая архивная публикация запланирована на {}", next);
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
ScheduledExecutorService e = executor;
|
||||
if (e != null) e.shutdownNow();
|
||||
}
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.archive.ArchivePublishJob;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.dao.ArchivePublicationDAO;
|
||||
import shine.db.dao.ArchiveImportDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
|
||||
public final class ArchivePublisherService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
|
||||
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
|
||||
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
|
||||
private final ShineArchiveWriter writer = new ShineArchiveWriter();
|
||||
private final ArweaveArchiveService arweave;
|
||||
private final SolanaArchiveHeadWriter solana;
|
||||
private final byte[] archiveSigningSeed32;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
cfg.validateForStart();
|
||||
Files.createDirectories(cfg.workDir());
|
||||
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
|
||||
this.arweave = new ArweaveArchiveService(cfg);
|
||||
this.solana = new SolanaArchiveHeadWriter(cfg);
|
||||
this.solana.validateKeysAgainstPda();
|
||||
backfillOwnLocationIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* После обновления старого publisher-сервера заполняет универсальный location index
|
||||
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
|
||||
*/
|
||||
private void backfillOwnLocationIndex() throws Exception {
|
||||
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
|
||||
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
|
||||
if (finalized.isEmpty()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
int updated = 0;
|
||||
for (BlockchainStateEntry state : stateDao.listAll()) {
|
||||
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
|
||||
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
|
||||
if (cursor == null) continue;
|
||||
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
|
||||
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
|
||||
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
|
||||
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
|
||||
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
|
||||
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
|
||||
updated++;
|
||||
}
|
||||
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
|
||||
}
|
||||
|
||||
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
|
||||
public boolean resumeUnfinishedJob() throws Exception {
|
||||
if (!running.compareAndSet(false, true)) return false;
|
||||
try {
|
||||
ArchivePublishJob job = archiveDao.findUnfinishedJob();
|
||||
if (job == null) return false;
|
||||
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
|
||||
job.id(), job.bigBlockNumber(), job.status());
|
||||
process(job.id());
|
||||
return true;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
|
||||
public boolean runCycle() throws Exception {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ArchivePublishJob job = archiveDao.findUnfinishedJob();
|
||||
if (job == null) {
|
||||
job = createFrozenJob();
|
||||
if (job == null) {
|
||||
log.info("Archive publisher: новых SHiNE-блоков нет");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
|
||||
job.id(), job.bigBlockNumber(), job.status());
|
||||
}
|
||||
process(job.id());
|
||||
return true;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ArchivePublishJob createFrozenJob() throws Exception {
|
||||
List<ArchivePublishJobChain> chains = new ArrayList<>();
|
||||
for (BlockchainStateEntry listed : stateDao.listAll()) {
|
||||
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
|
||||
String name = listed.getBlockchainName();
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(name);
|
||||
lock.lock();
|
||||
try {
|
||||
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
|
||||
if (state == null || state.getLastBlockNumber() < 0) continue;
|
||||
ArchiveChainCursor cursor = archiveDao.getCursor(name);
|
||||
long from;
|
||||
byte[] previousSourceHash;
|
||||
Long prevBig = null;
|
||||
byte[] prevBigHash = null;
|
||||
Long prevChunkOffset = null;
|
||||
Long prevChunkSize = null;
|
||||
|
||||
if (cursor == null) {
|
||||
from = 0L;
|
||||
previousSourceHash = new byte[32];
|
||||
} else {
|
||||
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
|
||||
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
|
||||
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
|
||||
}
|
||||
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
|
||||
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
|
||||
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
|
||||
}
|
||||
from = cursorBlock + 1L;
|
||||
previousSourceHash = cursor.lastArchivedSourceBlockHash();
|
||||
prevBig = cursor.lastArchiveBigBlockNumber();
|
||||
prevBigHash = cursor.lastArchiveBigBlockHash();
|
||||
prevChunkOffset = cursor.lastChunkOffset();
|
||||
prevChunkSize = cursor.lastChunkSize();
|
||||
}
|
||||
|
||||
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
|
||||
if (from > to) continue;
|
||||
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
|
||||
byte[] lastHash = state.getLastBlockHash();
|
||||
if (lastHash == null || lastHash.length != 32) {
|
||||
lastHash = blocksDao.getHashByNumber(name, (int) to);
|
||||
}
|
||||
require32(lastHash, "last source block hash " + name);
|
||||
chains.add(new ArchivePublishJobChain(
|
||||
0L, name, from, to, previousSourceHash, lastHash,
|
||||
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
if (chains.isEmpty()) return null;
|
||||
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
|
||||
long bigBlock = archiveDao.nextBigBlockNumber();
|
||||
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
|
||||
long createdAt = System.currentTimeMillis();
|
||||
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
|
||||
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
|
||||
return archiveDao.getJob(id);
|
||||
}
|
||||
|
||||
private void process(long jobId) throws Exception {
|
||||
while (true) {
|
||||
ArchivePublishJob job = archiveDao.getJob(jobId);
|
||||
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
|
||||
switch (job.status()) {
|
||||
case "SNAPSHOT_CREATED" -> buildFile(job);
|
||||
case "FILE_BUILT" -> uploadArweave(job);
|
||||
case "ARWEAVE_UPLOADED" -> waitArweave(job);
|
||||
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
|
||||
case "SOLANA_SUBMITTED" -> waitSolana(job);
|
||||
case "SOLANA_FINALIZED" -> commitCursors(job);
|
||||
case "CURSORS_COMMITTED" -> {
|
||||
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
|
||||
return;
|
||||
}
|
||||
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
|
||||
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void buildFile(ArchivePublishJob job) throws Exception {
|
||||
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
|
||||
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
|
||||
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
|
||||
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
|
||||
lock.lock();
|
||||
try {
|
||||
if (ch.fromSourceBlockNumber() > 0) {
|
||||
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
|
||||
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
|
||||
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
|
||||
}
|
||||
}
|
||||
List<BlockEntry> range = blocksDao.listRangeByNumber(
|
||||
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
|
||||
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
|
||||
byte[] last = range.get(range.size()-1).getBlockHash();
|
||||
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
|
||||
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
|
||||
}
|
||||
records.put(ch.blockchainName(), range);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
|
||||
Path file = ArchiveFileNames.pendingFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
|
||||
ShineArchiveWriter.BuildResult result = writer.build(
|
||||
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
|
||||
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
|
||||
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
|
||||
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
|
||||
}
|
||||
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
|
||||
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
|
||||
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
|
||||
}
|
||||
|
||||
private void uploadArweave(ArchivePublishJob job) throws Exception {
|
||||
Path file = requireLocalFile(job);
|
||||
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
|
||||
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
|
||||
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
|
||||
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
|
||||
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
|
||||
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
|
||||
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
|
||||
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
|
||||
job.bigBlockNumber(), upload.txId(), uploadedFile);
|
||||
}
|
||||
|
||||
private void waitArweave(ArchivePublishJob job) throws Exception {
|
||||
ensureUploadedLocalName(job);
|
||||
String tx = b64url(job.arweaveTxId());
|
||||
int confirmations = arweave.waitForConfirmations(tx);
|
||||
archiveDao.markArweaveConfirmed(job.id(), confirmations);
|
||||
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
|
||||
}
|
||||
|
||||
|
||||
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
|
||||
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
|
||||
require32(txId, "arweave tx id");
|
||||
Path target = ArchiveFileNames.uploadedFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
|
||||
Files.createDirectories(target.toAbsolutePath().getParent());
|
||||
if (!currentFile.equals(target)) {
|
||||
try {
|
||||
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
archiveDao.updateLocalArchivePath(job.id(), target.toString());
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
|
||||
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
|
||||
require32(job.arweaveTxId(), "job.arweave_tx_id");
|
||||
Path target = ArchiveFileNames.uploadedFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
|
||||
if (Files.isRegularFile(target)) {
|
||||
if (!target.toString().equals(job.localArchivePath())) {
|
||||
archiveDao.updateLocalArchivePath(job.id(), target.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
|
||||
Path current = Path.of(job.localArchivePath());
|
||||
if (Files.isRegularFile(current)) {
|
||||
markLocalFileUploaded(job, current, job.arweaveTxId());
|
||||
}
|
||||
}
|
||||
|
||||
private void submitSolana(ArchivePublishJob job) throws Exception {
|
||||
require32(job.arweaveTxId(), "job.arweave_tx_id");
|
||||
require32(job.archiveHash(), "job.archive_hash");
|
||||
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
|
||||
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
|
||||
}
|
||||
|
||||
private void waitSolana(ArchivePublishJob job) throws Exception {
|
||||
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
|
||||
solana.waitFinalized(job.solanaSignature());
|
||||
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
|
||||
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
|
||||
}
|
||||
archiveDao.markSolanaFinalized(job.id());
|
||||
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
|
||||
}
|
||||
|
||||
private void commitCursors(ArchivePublishJob job) throws Exception {
|
||||
archiveDao.commitCursors(job.id(), job.archiveHash());
|
||||
|
||||
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
|
||||
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
|
||||
String txId = b64url(job.arweaveTxId());
|
||||
long now = System.currentTimeMillis();
|
||||
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
|
||||
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
|
||||
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
|
||||
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
|
||||
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
|
||||
}
|
||||
}
|
||||
|
||||
private static Path requireLocalFile(ArchivePublishJob job) {
|
||||
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
|
||||
Path p = Path.of(job.localArchivePath());
|
||||
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static void require32(byte[] v, String name) {
|
||||
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
|
||||
}
|
||||
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
|
||||
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.*;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
|
||||
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
|
||||
*/
|
||||
public final class ArweaveArchiveService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final HttpClient http;
|
||||
private volatile Wallet wallet;
|
||||
|
||||
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
}
|
||||
|
||||
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
|
||||
|
||||
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
|
||||
throws Exception {
|
||||
long dataSize = Files.size(archiveFile);
|
||||
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
|
||||
Wallet w = wallet();
|
||||
String gateway = trimGateway(cfg.arweaveGateway());
|
||||
|
||||
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
|
||||
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||
String reward = getRequiredText(gateway, "/price/" + dataSize);
|
||||
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
|
||||
verifyBalance(gateway, w.address(), reward);
|
||||
|
||||
List<List<byte[]>> tagList = new ArrayList<>();
|
||||
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
|
||||
addTag(jsonTags, tagList, "App-Name", "SHiNE");
|
||||
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
|
||||
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
|
||||
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
|
||||
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
|
||||
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
|
||||
|
||||
byte[] signaturePayload = deepHash(List.of(
|
||||
utf8("2"),
|
||||
b64Decode(w.owner()),
|
||||
new byte[0],
|
||||
utf8("0"),
|
||||
utf8(reward),
|
||||
b64Decode(anchor),
|
||||
tagList,
|
||||
utf8(Long.toString(dataSize)),
|
||||
merkle.dataRoot()
|
||||
));
|
||||
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
|
||||
byte[] txIdBytes = sha256(rawSignature);
|
||||
String txId = b64(txIdBytes);
|
||||
|
||||
Map<String,Object> tx = new LinkedHashMap<>();
|
||||
tx.put("format", 2);
|
||||
tx.put("id", txId);
|
||||
tx.put("last_tx", anchor);
|
||||
tx.put("owner", w.owner());
|
||||
tx.put("tags", jsonTags);
|
||||
tx.put("target", "");
|
||||
tx.put("quantity", "0");
|
||||
tx.put("data_root", b64(merkle.dataRoot()));
|
||||
tx.put("data_size", Long.toString(dataSize));
|
||||
tx.put("data", "");
|
||||
tx.put("reward", reward);
|
||||
tx.put("signature", b64(rawSignature));
|
||||
|
||||
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
|
||||
|
||||
for (int i=0; i<merkle.chunks().size(); i++) {
|
||||
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
|
||||
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
|
||||
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
|
||||
Map<String,Object> body = new LinkedHashMap<>();
|
||||
body.put("data_root", b64(merkle.dataRoot()));
|
||||
body.put("data_size", Long.toString(dataSize));
|
||||
body.put("data_path", b64(proof.proof()));
|
||||
body.put("offset", Long.toString(proof.offset()));
|
||||
body.put("chunk", b64(chunk));
|
||||
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
|
||||
}
|
||||
return new UploadResult(txId, txIdBytes, gateway);
|
||||
}
|
||||
|
||||
/** Ждёт включения tx в блок и заданного количества подтверждений. */
|
||||
public int waitForConfirmations(String txId) throws Exception {
|
||||
if (cfg.arweaveMinConfirmations() <= 0) return 0;
|
||||
String gateway = trimGateway(cfg.arweaveGateway());
|
||||
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
|
||||
int last = 0;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
|
||||
.timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() == 200) {
|
||||
JsonNode json = MAPPER.readTree(resp.body());
|
||||
last = json.path("number_of_confirmations").asInt(0);
|
||||
if (last >= cfg.arweaveMinConfirmations()) return last;
|
||||
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
|
||||
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
|
||||
}
|
||||
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
|
||||
}
|
||||
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
|
||||
}
|
||||
|
||||
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
|
||||
Exception last = null;
|
||||
for (int attempt=1; attempt<=12; attempt++) {
|
||||
try {
|
||||
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
|
||||
if ((index + 1) % 25 == 0 || index + 1 == total) {
|
||||
log.info("Arweave chunks: {}/{}", index + 1, total);
|
||||
}
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
if (attempt == 12) break;
|
||||
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
|
||||
Thread.sleep(5_000L);
|
||||
}
|
||||
}
|
||||
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
|
||||
}
|
||||
|
||||
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
|
||||
String json = MAPPER.writeValueAsString(body);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyBalance(String gateway, String address, String reward) throws Exception {
|
||||
if (address == null || address.isBlank()) return;
|
||||
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
|
||||
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
|
||||
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
|
||||
}
|
||||
}
|
||||
|
||||
private String getRequiredText(String gateway, String path) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
|
||||
}
|
||||
String s = resp.body() == null ? "" : resp.body().trim();
|
||||
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
|
||||
return s;
|
||||
}
|
||||
|
||||
private Wallet wallet() throws Exception {
|
||||
Wallet local = wallet;
|
||||
if (local != null) return local;
|
||||
synchronized (this) {
|
||||
if (wallet != null) return wallet;
|
||||
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
|
||||
String owner = required(jwk, "n");
|
||||
PrivateKey pk = buildPrivateKey(jwk);
|
||||
String address = b64(sha256(b64Decode(owner)));
|
||||
wallet = new Wallet(owner, address, pk);
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
|
||||
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
|
||||
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
|
||||
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
|
||||
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
|
||||
big(required(jwk,"dq")), big(required(jwk,"qi")));
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
|
||||
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
|
||||
Signature signature = Signature.getInstance("RSASSA-PSS");
|
||||
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
|
||||
signature.initSign(key);
|
||||
signature.update(payload);
|
||||
return signature.sign();
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object data) throws GeneralSecurityException {
|
||||
if (data instanceof List<?> list) {
|
||||
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
|
||||
return deepHashChunks(list, sha384(tag));
|
||||
}
|
||||
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
|
||||
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
|
||||
byte[] cur = acc;
|
||||
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
|
||||
byte[] n=utf8(name), v=utf8(value);
|
||||
Map<String,String> item = new LinkedHashMap<>();
|
||||
item.put("name", b64(n)); item.put("value", b64(v));
|
||||
json.add(item); raw.add(List.of(n,v));
|
||||
}
|
||||
|
||||
private static String required(JsonNode node, String field) {
|
||||
String s = node == null ? "" : node.path(field).asText("").trim();
|
||||
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
|
||||
return s;
|
||||
}
|
||||
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
|
||||
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
|
||||
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] b64Decode(String s) { return B64D.decode(s); }
|
||||
private static String b64(byte[] b) { return B64.encodeToString(b); }
|
||||
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
|
||||
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
|
||||
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
|
||||
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
|
||||
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
|
||||
|
||||
private record Wallet(String owner, String address, PrivateKey privateKey) {}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package server.archive;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
|
||||
*/
|
||||
final class ArweaveMerkle {
|
||||
static final int MAX_CHUNK_SIZE = 256 * 1024;
|
||||
static final int MIN_CHUNK_SIZE = 32 * 1024;
|
||||
|
||||
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
|
||||
long size() { return maxByteRange - minByteRange; }
|
||||
}
|
||||
record Proof(long offset, byte[] proof) {}
|
||||
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
|
||||
|
||||
private sealed interface Node permits Leaf, Branch {
|
||||
byte[] id();
|
||||
long maxByteRange();
|
||||
}
|
||||
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
|
||||
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
|
||||
|
||||
private ArweaveMerkle() {}
|
||||
|
||||
static Prepared prepare(Path file) throws IOException {
|
||||
long fileSize = Files.size(file);
|
||||
List<Chunk> chunks = chunkFile(file, fileSize);
|
||||
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||
for (Chunk c : chunks) {
|
||||
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||
}
|
||||
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
|
||||
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
|
||||
// но сам нулевой chunk/proof не загружает.
|
||||
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||
chunks = new ArrayList<>(chunks);
|
||||
chunks.remove(chunks.size() - 1);
|
||||
proofs.remove(proofs.size() - 1);
|
||||
}
|
||||
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||
}
|
||||
|
||||
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = fileSize;
|
||||
long cursor = 0;
|
||||
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
|
||||
while (remaining >= MAX_CHUNK_SIZE) {
|
||||
long chunkSize = MAX_CHUNK_SIZE;
|
||||
long next = remaining - MAX_CHUNK_SIZE;
|
||||
if (next > 0 && next < MIN_CHUNK_SIZE) {
|
||||
chunkSize = (remaining + 1) / 2;
|
||||
}
|
||||
byte[] data = readExact(in, (int)chunkSize);
|
||||
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = readExact(in, (int)remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Node buildLayers(List<Node> nodes) {
|
||||
if (nodes.size() < 2) return nodes.get(0);
|
||||
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
|
||||
for (int i = 0; i < nodes.size(); i += 2) {
|
||||
Node left = nodes.get(i);
|
||||
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
|
||||
if (right == null) {
|
||||
next.add(left);
|
||||
continue;
|
||||
}
|
||||
long byteRange = left.maxByteRange();
|
||||
byte[] id = sha256(concat(
|
||||
sha256(left.id()),
|
||||
sha256(right.id()),
|
||||
sha256(note(byteRange))
|
||||
));
|
||||
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
|
||||
}
|
||||
return buildLayers(next);
|
||||
}
|
||||
|
||||
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
|
||||
if (node instanceof Leaf leaf) {
|
||||
out.add(new Proof(leaf.maxByteRange() - 1,
|
||||
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
|
||||
return;
|
||||
}
|
||||
Branch b = (Branch) node;
|
||||
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
|
||||
resolveProofs(b.left(), partial, out);
|
||||
resolveProofs(b.right(), partial, out);
|
||||
}
|
||||
|
||||
static byte[] readChunk(Path file, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
raf.seek(c.minByteRange());
|
||||
byte[] data = new byte[(int)len];
|
||||
raf.readFully(data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readExact(InputStream in, int len) throws IOException {
|
||||
byte[] out = new byte[len];
|
||||
int pos = 0;
|
||||
while (pos < len) {
|
||||
int n = in.read(out, pos, len - pos);
|
||||
if (n < 0) throw new EOFException("Неожиданный EOF");
|
||||
pos += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] note(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative note");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 31; i >= 0 && v != 0; i--) {
|
||||
out[i] = (byte)(v & 0xff);
|
||||
v >>>= 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] data) {
|
||||
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... arrays) {
|
||||
int len = 0;
|
||||
for (byte[] a : arrays) len = Math.addExact(len, a.length);
|
||||
byte[] out = new byte[len];
|
||||
int p=0;
|
||||
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package server.archive;
|
||||
|
||||
import sync.util.Base58Util;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
|
||||
public final class ShineArchiveReader {
|
||||
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
|
||||
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
|
||||
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
|
||||
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
|
||||
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
|
||||
|
||||
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
|
||||
long fileSize = Files.size(file);
|
||||
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
|
||||
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
|
||||
|
||||
byte[] computedHash = digestPrefix(file, fileSize - 96);
|
||||
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
|
||||
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
|
||||
in.readFully(magic);
|
||||
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
|
||||
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
|
||||
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
|
||||
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
|
||||
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
|
||||
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
|
||||
long chunksCount=u32(in); long recordsCount=u32(in);
|
||||
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
|
||||
throw new IOException("Unsupported references table");
|
||||
}
|
||||
String creator=readUtf8(in,creatorLen);
|
||||
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
|
||||
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
|
||||
}
|
||||
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
|
||||
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
|
||||
|
||||
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
|
||||
for(long i=0;i<refsCount;i++) {
|
||||
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
|
||||
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
|
||||
}
|
||||
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
|
||||
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
|
||||
|
||||
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
|
||||
long parsedRecords=0;
|
||||
for(long ci=0;ci<chunksCount;ci++) {
|
||||
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
|
||||
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
|
||||
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
|
||||
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
|
||||
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
|
||||
for(long ri=0;ri<count;ri++) {
|
||||
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
|
||||
records.add(readFixed(in,(int)n)); parsedRecords++;
|
||||
}
|
||||
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
|
||||
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
|
||||
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
|
||||
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
|
||||
}
|
||||
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
|
||||
|
||||
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
|
||||
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
|
||||
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
|
||||
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
|
||||
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
|
||||
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
|
||||
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
|
||||
byte[] root=Base58Util.decode(rootPublicKeyBase58);
|
||||
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
|
||||
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
|
||||
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
|
||||
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
|
||||
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
|
||||
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
|
||||
MessageDigest md=MessageDigest.getInstance("SHA-256");
|
||||
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
|
||||
byte[] buf=new byte[1024*1024]; long left=bytes;
|
||||
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
|
||||
}
|
||||
return md.digest();
|
||||
}
|
||||
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
|
||||
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
|
||||
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
package server.archive;
|
||||
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Writer бинарного SHINE-ARCHIVE v1.0.
|
||||
*
|
||||
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
|
||||
*/
|
||||
public final class ShineArchiveWriter {
|
||||
public static final byte VERSION_MAJOR = 1;
|
||||
public static final byte VERSION_MINOR = 0;
|
||||
public static final byte REFERENCES_FULL = 0;
|
||||
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
|
||||
public static final int REFERENCE_ENTRY_SIZE = 68;
|
||||
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
|
||||
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public record ChunkPosition(long offset, long size) {}
|
||||
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
|
||||
|
||||
public BuildResult build(
|
||||
Path finalPath,
|
||||
String creatorLogin,
|
||||
long bigBlockNumber,
|
||||
long createdAtMs,
|
||||
List<ArchiveBigBlockRef> previousBigBlocks,
|
||||
List<ArchivePublishJobChain> chains,
|
||||
Map<String, List<BlockEntry>> recordsByBlockchain,
|
||||
byte[] signingPrivateSeed32,
|
||||
long maxFileBytes
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(finalPath, "finalPath");
|
||||
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
|
||||
Objects.requireNonNull(chains, "chains");
|
||||
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
|
||||
requireU32(bigBlockNumber, "big_block_number");
|
||||
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
|
||||
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
|
||||
}
|
||||
|
||||
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
|
||||
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
|
||||
throw new IllegalArgumentException("Слишком много references/chunks для v1");
|
||||
}
|
||||
|
||||
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
|
||||
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
|
||||
|
||||
long totalRecords = 0;
|
||||
for (ArchivePublishJobChain ch : sortedChains) {
|
||||
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
|
||||
if (rs == null || rs.isEmpty()) {
|
||||
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
|
||||
}
|
||||
totalRecords += rs.size();
|
||||
requireU32(totalRecords, "total_user_records_count");
|
||||
}
|
||||
|
||||
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
|
||||
for (int i = 0; i < previousBigBlocks.size(); i++) {
|
||||
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
|
||||
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
|
||||
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
|
||||
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
|
||||
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
|
||||
}
|
||||
|
||||
Files.createDirectories(finalPath.toAbsolutePath().getParent());
|
||||
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
|
||||
Files.deleteIfExists(tmp);
|
||||
|
||||
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
|
||||
MessageDigest digest = sha256();
|
||||
|
||||
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
|
||||
CountingOutputStream counting = new CountingOutputStream(fileOut);
|
||||
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
|
||||
DataOutputStream out = new DataOutputStream(digestOut)) {
|
||||
|
||||
// ---------- header ----------
|
||||
out.write(MAGIC);
|
||||
out.writeByte(VERSION_MAJOR);
|
||||
out.writeByte(VERSION_MINOR);
|
||||
|
||||
long headerSize = fixedHeaderSize() + loginBytes.length;
|
||||
writeU32(out, headerSize);
|
||||
writeU32(out, bigBlockNumber);
|
||||
out.writeLong(createdAtMs);
|
||||
out.writeByte(loginBytes.length);
|
||||
out.writeByte(REFERENCES_FULL);
|
||||
writeU32(out, previousBigBlocks.size());
|
||||
out.writeShort(REFERENCE_ENTRY_SIZE);
|
||||
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
|
||||
writeU32(out, parentIndex);
|
||||
writeU32(out, sortedChains.size());
|
||||
writeU32(out, totalRecords);
|
||||
out.write(loginBytes);
|
||||
|
||||
// ---------- полный список предыдущих больших блоков ----------
|
||||
for (ArchiveBigBlockRef ref : previousBigBlocks) {
|
||||
writeU32(out, ref.bigBlockNumber());
|
||||
out.write(ref.archiveHash());
|
||||
out.write(ref.arweaveTxId());
|
||||
}
|
||||
|
||||
// ---------- по одному chunk на blockchain_name ----------
|
||||
for (ArchivePublishJobChain ch : sortedChains) {
|
||||
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
|
||||
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
|
||||
validateFrozenRange(ch, records);
|
||||
|
||||
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
|
||||
long chunkSize = calculateChunkSize(name.length, records);
|
||||
requireU32(chunkSize, "chunk_size");
|
||||
long chunkOffset = counting.getCount();
|
||||
requireU32(chunkOffset, "chunk_offset");
|
||||
if (chunkOffset + chunkSize > maxFileBytes) {
|
||||
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
|
||||
}
|
||||
|
||||
writeU32(out, chunkSize);
|
||||
out.writeByte(name.length);
|
||||
out.write(name);
|
||||
writeU32(out, records.size());
|
||||
for (BlockEntry record : records) {
|
||||
byte[] raw = record.getBlockBytes();
|
||||
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
|
||||
writeU32(out, raw.length);
|
||||
out.write(raw);
|
||||
}
|
||||
|
||||
if (ch.previousArchiveBigBlockNumber() == null) {
|
||||
writeU32(out, NO_REFERENCE_U32);
|
||||
writeU32(out, 0);
|
||||
writeU32(out, 0);
|
||||
} else {
|
||||
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
|
||||
if (refIndex == null) {
|
||||
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
|
||||
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
|
||||
}
|
||||
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
|
||||
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
|
||||
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
|
||||
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
|
||||
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
|
||||
}
|
||||
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
|
||||
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
|
||||
}
|
||||
writeU32(out, refIndex);
|
||||
writeU32(out, ch.previousChunkOffset());
|
||||
writeU32(out, ch.previousChunkSize());
|
||||
}
|
||||
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
|
||||
}
|
||||
|
||||
// ---------- footer, login входит в hash ----------
|
||||
out.writeByte(loginBytes.length);
|
||||
out.write(loginBytes);
|
||||
out.flush();
|
||||
|
||||
byte[] archiveHash = digest.digest();
|
||||
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
|
||||
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
|
||||
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
|
||||
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
|
||||
|
||||
// hash/signature не должны попасть в hash самого блока.
|
||||
digestOut.on(false);
|
||||
out.write(archiveHash);
|
||||
out.write(signature);
|
||||
out.flush();
|
||||
|
||||
long finalSize = counting.getCount();
|
||||
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
|
||||
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Files.deleteIfExists(tmp);
|
||||
if (e instanceof IOException ioe) throw ioe;
|
||||
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
|
||||
}
|
||||
|
||||
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
|
||||
try {
|
||||
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
byte[] hash = readStoredArchiveHash(finalPath);
|
||||
long size = Files.size(finalPath);
|
||||
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
|
||||
}
|
||||
|
||||
/** Проверяет локальный файл перед оплатой Arweave. */
|
||||
public static byte[] verifyLocalFile(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
|
||||
byte[] stored = readStoredArchiveHash(file);
|
||||
MessageDigest md = sha256();
|
||||
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
long left = hashInputSize;
|
||||
while (left > 0) {
|
||||
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
|
||||
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
|
||||
md.update(buf, 0, n);
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
byte[] actual = md.digest();
|
||||
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
|
||||
return actual;
|
||||
}
|
||||
|
||||
public static byte[] readStoredArchiveHash(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < 96) throw new EOFException("Файл слишком короткий");
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
raf.seek(size - 96);
|
||||
byte[] hash = new byte[32];
|
||||
raf.readFully(hash);
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private static long fixedHeaderSize() {
|
||||
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
|
||||
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
|
||||
}
|
||||
|
||||
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
|
||||
long size = 4L + 1L + nameLen + 4L + 12L;
|
||||
for (BlockEntry e : records) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
if (raw == null) throw new IllegalArgumentException("raw record is null");
|
||||
size += 4L + raw.length;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
|
||||
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
|
||||
if (expected <= 0 || expected != records.size()) {
|
||||
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
|
||||
+ ": ожидалось=" + expected + ", получено=" + records.size());
|
||||
}
|
||||
long n = ch.fromSourceBlockNumber();
|
||||
for (BlockEntry e : records) {
|
||||
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
|
||||
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
|
||||
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
|
||||
}
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] utf8U8(String value, String name) {
|
||||
if (value == null) throw new IllegalArgumentException(name + " == null");
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void require32(byte[] v, String name) {
|
||||
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
|
||||
}
|
||||
|
||||
private static void requireU32(long v, String name) {
|
||||
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
|
||||
}
|
||||
|
||||
private static void writeU32(DataOutputStream out, long v) throws IOException {
|
||||
requireU32(v, "u32");
|
||||
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try { return MessageDigest.getInstance("SHA-256"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
/** OutputStream со счётчиком без ограничения int. */
|
||||
private static final class CountingOutputStream extends FilterOutputStream {
|
||||
private long count;
|
||||
CountingOutputStream(OutputStream out) { super(out); }
|
||||
long getCount() { return count; }
|
||||
@Override public void write(int b) throws IOException { out.write(b); count++; }
|
||||
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
|
||||
}
|
||||
|
||||
public static final class ArchiveTooLargeException extends IOException {
|
||||
public ArchiveTooLargeException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sync.codec.ShineUsersCodec;
|
||||
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
|
||||
import sync.util.Base58Util;
|
||||
import sync.util.SolanaPdaUtil;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
|
||||
* Отдельной Solana-инструкции для архива нет.
|
||||
*/
|
||||
public final class SolanaArchiveHeadWriter {
|
||||
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
||||
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
|
||||
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
|
||||
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
|
||||
private static final String USER_SEED_PREFIX = "user_login=";
|
||||
private static final String ECONOMY_SEED = "shine_users_economy_config";
|
||||
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
|
||||
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final byte[] rootSeed32;
|
||||
private final byte[] clientSeed32;
|
||||
|
||||
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
|
||||
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
|
||||
}
|
||||
|
||||
public record SubmitResult(String signature, String userPda) {}
|
||||
|
||||
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
|
||||
public void validateKeysAgainstPda() throws Exception {
|
||||
Current current = loadCurrent();
|
||||
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
|
||||
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
|
||||
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
|
||||
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
|
||||
}
|
||||
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
|
||||
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
|
||||
}
|
||||
}
|
||||
|
||||
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
|
||||
require32(arweaveTxId32, "Arweave TX ID");
|
||||
require32(archiveHash32, "archive hash");
|
||||
|
||||
Current current = loadCurrent();
|
||||
UserPdaSnapshot old = current.snapshot;
|
||||
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
|
||||
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
|
||||
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
|
||||
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
|
||||
}
|
||||
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
|
||||
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
|
||||
}
|
||||
|
||||
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
|
||||
int currentRecordLen = u16le(currentRaw, 7);
|
||||
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
|
||||
byte[] prevHash = sha256(currentUnsigned);
|
||||
|
||||
long updatedAt = System.currentTimeMillis();
|
||||
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
|
||||
String archiveHashHex = hex(archiveHash32);
|
||||
String zeroSig = Base58Util.encode(new byte[64]);
|
||||
|
||||
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
|
||||
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
|
||||
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
|
||||
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
|
||||
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
|
||||
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
|
||||
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
|
||||
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
|
||||
);
|
||||
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
|
||||
int nextRecordLen = u16le(nextFull, 7);
|
||||
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
|
||||
byte[] nextUnsignedHash = sha256(nextUnsigned);
|
||||
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
|
||||
|
||||
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
|
||||
byte[] lastStateHash = sha256(buildLastBlockState(old));
|
||||
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
|
||||
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
|
||||
|
||||
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
|
||||
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
|
||||
|
||||
String blockhash = getLatestBlockhash();
|
||||
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
|
||||
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
|
||||
byte[] transaction = serializeTransaction(clientSignature, message);
|
||||
String signature = sendTransaction(transaction);
|
||||
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
|
||||
return new SubmitResult(signature, current.userPda);
|
||||
}
|
||||
|
||||
public void waitFinalized(String signature) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
|
||||
JsonNode values = result.path("value");
|
||||
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
|
||||
if (status != null && !status.isNull()) {
|
||||
if (status.hasNonNull("err")) {
|
||||
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
|
||||
}
|
||||
String confirmation = status.path("confirmationStatus").asText("");
|
||||
if ("finalized".equals(confirmation)) return;
|
||||
}
|
||||
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
|
||||
}
|
||||
throw new IOException("Timeout ожидания Solana finalized: " + signature);
|
||||
}
|
||||
|
||||
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
|
||||
Current current = loadCurrent();
|
||||
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
|
||||
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
|
||||
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
|
||||
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
|
||||
}
|
||||
|
||||
private Current loadCurrent() throws Exception {
|
||||
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
|
||||
String userPda = SolanaPdaUtil.findProgramAddress(
|
||||
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
|
||||
cfg.solanaUsersProgramId());
|
||||
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
|
||||
JsonNode value = accountResult.path("value");
|
||||
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
|
||||
String owner = value.path("owner").asText("");
|
||||
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
|
||||
JsonNode data = value.path("data");
|
||||
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
|
||||
String rawB64 = data.get(0).asText();
|
||||
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
|
||||
return new Current(userPda, snapshot);
|
||||
}
|
||||
|
||||
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
|
||||
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(4); // IX_UPDATE_USER_PDA
|
||||
strU8(out, s.login());
|
||||
out.write(Base58Util.decode(s.recoveryKey()));
|
||||
out.write(Base58Util.decode(s.rootKey()));
|
||||
u64le(out, s.createdAtMs());
|
||||
u64le(out, updatedAt);
|
||||
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
|
||||
out.write(prevHash);
|
||||
u64le(out, 0L); // additional_limit
|
||||
out.write(Base58Util.decode(s.clientKey()));
|
||||
out.write(Base58Util.decode(s.blockchainKey()));
|
||||
strU8(out, s.blockchainName());
|
||||
u64le(out, s.usedBytes());
|
||||
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
|
||||
out.write(unhex(s.lastBlockHash()));
|
||||
out.write(Base58Util.decode(s.lastBlockSignature()));
|
||||
strU8(out, s.arweaveTxId());
|
||||
out.write(s.isServer() ? 1 : 0);
|
||||
if (s.isServer()) {
|
||||
out.write(s.addressFormatType() & 0xff);
|
||||
out.write(s.addressFormatVersion() & 0xff);
|
||||
strU8(out, s.serverAddress());
|
||||
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
|
||||
out.write(s.syncServers().size());
|
||||
for (String v : s.syncServers()) strU8(out, v);
|
||||
}
|
||||
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
|
||||
out.write(s.accessServers().size());
|
||||
for (String v : s.accessServers()) strU8(out, v);
|
||||
out.write(s.sessionsMode() & 0xff);
|
||||
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
|
||||
out.write(s.sessions().size());
|
||||
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
|
||||
out.write(session.sessionType() & 0xff);
|
||||
out.write(session.sessionVersion() & 0xff);
|
||||
strU8(out, session.sessionName());
|
||||
out.write(Base58Util.decode(session.sessionPubKey()));
|
||||
}
|
||||
out.write(s.trustedCount() & 0xff);
|
||||
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
|
||||
out.write(1);
|
||||
out.write(archiveTx);
|
||||
out.write(archiveHash);
|
||||
out.write(rootSig);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(LAST_BLOCK_STATE_PREFIX);
|
||||
strU8(out, s.login());
|
||||
strU8(out, s.blockchainName());
|
||||
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
|
||||
out.write(unhex(s.lastBlockHash()));
|
||||
u64le(out, s.usedBytes());
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
|
||||
String recentBlockhash, byte[] clientPub) throws IOException {
|
||||
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
|
||||
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
|
||||
|
||||
List<Account> accounts = List.of(
|
||||
new Account(Base58Util.encode(clientPub), true, true),
|
||||
new Account(userPda, false, true),
|
||||
new Account(inflow, false, true),
|
||||
new Account(SYSTEM_PROGRAM, false, false),
|
||||
new Account(SYSVAR_INSTRUCTIONS, false, false),
|
||||
new Account(economy, false, false),
|
||||
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
|
||||
new Account(ED25519_PROGRAM, false, false),
|
||||
new Account(cfg.solanaUsersProgramId(), false, false)
|
||||
);
|
||||
Map<String,Integer> ix = new HashMap<>();
|
||||
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
|
||||
|
||||
List<CompiledInstruction> instructions = new ArrayList<>();
|
||||
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
|
||||
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
|
||||
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
|
||||
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
|
||||
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
|
||||
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
|
||||
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
|
||||
}, updateData));
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(1); // numRequiredSignatures
|
||||
out.write(0); // numReadonlySignedAccounts
|
||||
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
|
||||
shortVec(out, accounts.size());
|
||||
for (Account a : accounts) {
|
||||
byte[] pk = Base58Util.decode(a.pubkey);
|
||||
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
|
||||
out.write(pk);
|
||||
}
|
||||
byte[] bh = Base58Util.decode(recentBlockhash);
|
||||
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
|
||||
out.write(bh);
|
||||
shortVec(out, instructions.size());
|
||||
for (CompiledInstruction ci : instructions) {
|
||||
out.write(ci.programIdIndex);
|
||||
shortVec(out, ci.accounts.length);
|
||||
for (int a : ci.accounts) out.write(a);
|
||||
shortVec(out, ci.data.length);
|
||||
out.write(ci.data);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private String getLatestBlockhash() throws Exception {
|
||||
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
|
||||
String blockhash = result.path("value").path("blockhash").asText("");
|
||||
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
|
||||
return blockhash;
|
||||
}
|
||||
|
||||
private String sendTransaction(byte[] tx) throws Exception {
|
||||
String txB64 = Base64.getEncoder().encodeToString(tx);
|
||||
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
|
||||
"encoding", "base64",
|
||||
"skipPreflight", false,
|
||||
"preflightCommitment", "confirmed",
|
||||
"maxRetries", 5
|
||||
))).path("result");
|
||||
String sig = result.asText("");
|
||||
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
|
||||
return sig;
|
||||
}
|
||||
|
||||
private JsonNode rpc(String method, List<?> params) throws Exception {
|
||||
Map<String,Object> body = new LinkedHashMap<>();
|
||||
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
|
||||
JsonNode root = JSON.readTree(resp.body());
|
||||
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
|
||||
return root;
|
||||
}
|
||||
|
||||
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
|
||||
if (signature64.length != 64) throw new IOException("Solana signature !=64");
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
shortVec(out, 1);
|
||||
out.write(signature64);
|
||||
out.write(message);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
|
||||
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
|
||||
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
|
||||
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
|
||||
byte[] data = new byte[msgOff + message.length];
|
||||
data[0]=1; data[1]=0;
|
||||
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
|
||||
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
|
||||
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
|
||||
System.arraycopy(signature64,0,data,sigOff,64);
|
||||
System.arraycopy(publicKey32,0,data,pkOff,32);
|
||||
System.arraycopy(message,0,data,msgOff,message.length);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static byte[] computeHeapData(int bytes) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
|
||||
}
|
||||
private static byte[] computeLimitData(int units) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void shortVec(ByteArrayOutputStream out, int value) {
|
||||
int rem=value;
|
||||
while (true) {
|
||||
int elem=rem & 0x7f; rem >>>=7;
|
||||
if (rem==0) { out.write(elem); return; }
|
||||
out.write(elem | 0x80);
|
||||
}
|
||||
}
|
||||
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
|
||||
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
|
||||
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
|
||||
}
|
||||
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
|
||||
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
|
||||
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
|
||||
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
|
||||
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
|
||||
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
|
||||
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
|
||||
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
|
||||
|
||||
private record Current(String userPda, UserPdaSnapshot snapshot) {}
|
||||
private record Account(String pubkey, boolean signer, boolean writable) {}
|
||||
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.archive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ArchiveFileNamesTest {
|
||||
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
|
||||
private static final ZoneId UTC = ZoneId.of("UTC");
|
||||
|
||||
@Test
|
||||
void usesTemporaryAndFinalArweaveNames() {
|
||||
byte[] tx = new byte[32];
|
||||
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
|
||||
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
|
||||
|
||||
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
|
||||
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
|
||||
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
|
||||
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizesLoginForFileSystemOnly() {
|
||||
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package server.archive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShineArchiveWriterTest {
|
||||
@TempDir Path dir;
|
||||
|
||||
@Test
|
||||
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
|
||||
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
|
||||
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
|
||||
ArchivePublishJobChain chain = new ArchivePublishJobChain(
|
||||
1, "alice-001", 10, 11, new byte[32], new byte[32],
|
||||
1L, new byte[32], 100L, 55L, null, null);
|
||||
|
||||
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
|
||||
file, "serverA", 2, 123456789L,
|
||||
List.of(previous), List.of(chain),
|
||||
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
|
||||
new byte[32], 4_000_000_000L);
|
||||
|
||||
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
|
||||
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
|
||||
|
||||
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
|
||||
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
|
||||
assertEquals(1, in.readUnsignedByte());
|
||||
assertEquals(0, in.readUnsignedByte());
|
||||
long headerSize = Integer.toUnsignedLong(in.readInt());
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(123456789L, in.readLong());
|
||||
int creatorLen = in.readUnsignedByte();
|
||||
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(68, in.readUnsignedShort());
|
||||
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
|
||||
assertEquals(headerSize, 51L + creatorLen);
|
||||
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
in.skipNBytes(64); // previous hash + txid
|
||||
|
||||
long chunkSize = Integer.toUnsignedLong(in.readInt());
|
||||
int nameLen = in.readUnsignedByte();
|
||||
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
|
||||
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
|
||||
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
|
||||
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockEntry block(int number, byte[] bytes) {
|
||||
BlockEntry e = new BlockEntry();
|
||||
e.setBlockNumber(number);
|
||||
e.setBlockBytes(bytes);
|
||||
e.setBlockHash(new byte[32]);
|
||||
e.setBlockSignature(new byte[64]);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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,25 +98,27 @@ 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;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 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_FRIEND = 14;
|
||||
/** Удалить из друзей. */
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
/** Удалить связь "жена/муж". */
|
||||
@@ -119,9 +139,8 @@ public final class MsgSubType {
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
// Legacy. Допускаем старые блоки 60/61, но новый UI их не создаёт и не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
/** Не знаю этого человека. */
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -129,13 +148,31 @@ public final class MsgSubType {
|
||||
/** Не подтверждаю, что сияющий. */
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE.
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/** Подтверждаю, что это действительно официальный аккаунт этого человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
/** Снимаю подтверждение официального аккаунта этого человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля 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;
|
||||
}
|
||||
|
||||
+11
-5
@@ -20,9 +20,11 @@ import java.util.Objects;
|
||||
* PARENT=50, UNPARENT=51
|
||||
* CHILD=52, UNCHILD=53
|
||||
* SIBLING=54, UNSIBLING=55
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
|
||||
* FRIEND=14, UNFRIEND=15
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||
* [4] lineCode
|
||||
@@ -66,7 +68,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,7 +185,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
@@ -202,7 +206,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
-7
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
+166
@@ -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());
|
||||
}
|
||||
}
|
||||
+53
-13
@@ -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) {
|
||||
|
||||
+157
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -66,19 +66,20 @@ public final class AppConfig {
|
||||
|
||||
/** Вернёт строку или пустую строку, если параметр не найден. */
|
||||
public String getStringOrEmpty(String name) {
|
||||
String value = properties.getProperty(name);
|
||||
String value = getParam(name);
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/** Можно добавить методы для удобства */
|
||||
/** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */
|
||||
public int getInt(String name, int defaultValue) {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Integer.parseInt(v);
|
||||
String v = getParam(name);
|
||||
return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim());
|
||||
}
|
||||
|
||||
/** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */
|
||||
public boolean getBoolean(String name, boolean defaultValue) {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
||||
String v = getParam(name);
|
||||
return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim());
|
||||
}
|
||||
|
||||
private static String toEnvName(String name) {
|
||||
|
||||
+7
-1
@@ -14,6 +14,12 @@ 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:";
|
||||
|
||||
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
@@ -31,4 +37,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" // вызов логгера
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,61 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
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 volatile boolean dmDialogStateBootstrapped;
|
||||
|
||||
private DbController() {
|
||||
this.delegate = PostgresDbController.getInstance();
|
||||
}
|
||||
|
||||
public static DbController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DbController.class) {
|
||||
if (instance == null) {
|
||||
DbController created = new DbController();
|
||||
instance = created;
|
||||
created.bootstrapDmDialogStateIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException {
|
||||
return delegate.getConnection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
|
||||
private void bootstrapDmDialogStateIfNeeded() {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
synchronized (this) {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
try (Connection connection = delegate.getConnection()) {
|
||||
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||
dmDialogStateBootstrapped = true;
|
||||
} catch (SQLException e) {
|
||||
instance = null;
|
||||
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,23 @@ 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,
|
||||
* 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,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (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;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Добавить в друзья. */
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
/** Удалить из друзей. */
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
@@ -67,12 +82,14 @@ public final class MsgSubType {
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
|
||||
@@ -97,10 +114,8 @@ public final class MsgSubType {
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
// Legacy 60/61: принимаем старые данные, но новый UI их не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
|
||||
/** Не знаю этого человека. */
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -109,17 +124,36 @@ public final class MsgSubType {
|
||||
/** Не подтверждаю, что сияющий. */
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE.
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/** Подтверждение официального аккаунта человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
|
||||
/** Снятие подтверждения официального аккаунта человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля 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,846 +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 = 12;
|
||||
|
||||
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();
|
||||
case 12 -> migrateToV12();
|
||||
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 void migrateToV12() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSignedMessagesReadAtColumn(c, st);
|
||||
backfillSignedMessagesReadAt(st);
|
||||
setSchemaVersion(c, 12);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v12 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v12 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");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
if (!columnExists(c, "signed_messages_v2", "read_at_ms")) {
|
||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER");
|
||||
}
|
||||
}
|
||||
|
||||
private static void backfillSignedMessagesReadAt(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
UPDATE signed_messages_v2 AS content
|
||||
SET read_at_ms = (
|
||||
SELECT MIN(receipt.time_ms)
|
||||
FROM signed_messages_v2 AS receipt
|
||||
WHERE receipt.message_type IN (3, 4)
|
||||
AND receipt.receipt_ref_base_key = content.base_key
|
||||
)
|
||||
WHERE content.message_type IN (1, 2)
|
||||
AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM signed_messages_v2 AS receipt
|
||||
WHERE receipt.message_type IN (3, 4)
|
||||
AND receipt.receipt_ref_base_key = content.base_key
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/**
|
||||
* Временная одноразовая миграция на переходе к 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Ссылка на уже успешно опубликованный большой архивный блок. */
|
||||
public record ArchiveBigBlockRef(
|
||||
long bigBlockNumber,
|
||||
byte[] archiveHash,
|
||||
byte[] arweaveTxId
|
||||
) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Последний известный архивный chunk конкретной blockchain на этом сервере. */
|
||||
public record ArchiveBlockchainLocation(
|
||||
String blockchainName,
|
||||
String publisherLogin,
|
||||
String arweaveTxId,
|
||||
byte[] archiveHash,
|
||||
long bigBlockNumber,
|
||||
long chunkOffset,
|
||||
long chunkSize,
|
||||
long sourceLastBlockNumber,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/**
|
||||
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
|
||||
*/
|
||||
public record ArchiveChainCursor(
|
||||
String blockchainName,
|
||||
long lastArchivedSourceBlockNumber,
|
||||
byte[] lastArchivedSourceBlockHash,
|
||||
long lastArchiveBigBlockNumber,
|
||||
byte[] lastArchiveBigBlockHash,
|
||||
long lastChunkOffset,
|
||||
long lastChunkSize,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
|
||||
public record ArchivePublishJob(
|
||||
long id,
|
||||
long bigBlockNumber,
|
||||
String status,
|
||||
long createdAtMs,
|
||||
String localArchivePath,
|
||||
byte[] archiveHash,
|
||||
byte[] arweaveTxId,
|
||||
Integer arweaveConfirmations,
|
||||
String solanaSignature,
|
||||
String errorText,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
|
||||
public record ArchivePublishJobChain(
|
||||
long jobId,
|
||||
String blockchainName,
|
||||
long fromSourceBlockNumber,
|
||||
long toSourceBlockNumber,
|
||||
byte[] previousSourceBlockHash,
|
||||
byte[] lastSourceBlockHash,
|
||||
Long previousArchiveBigBlockNumber,
|
||||
byte[] previousArchiveBigBlockHash,
|
||||
Long previousChunkOffset,
|
||||
Long previousChunkSize,
|
||||
Long newChunkOffset,
|
||||
Long newChunkSize
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/**
|
||||
* Текущая archive head trusted publisher-а из локальной таблицы,
|
||||
* которую уже заполнил обычный Solana users sync.
|
||||
*/
|
||||
public record ArchivePublisherHead(
|
||||
String login,
|
||||
String rootKey,
|
||||
String archiveHeadTxId,
|
||||
String archiveHeadHash,
|
||||
boolean archiveImported,
|
||||
String lastImportedArchiveTxId
|
||||
) {}
|
||||
@@ -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 нет.
|
||||
}
|
||||
}
|
||||
+58
@@ -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<>();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.archive.ArchiveBlockchainLocation;
|
||||
import shine.db.archive.ArchivePublisherHead;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
/** БД индекс последних archive chunks и локальное состояние импорта archive head. */
|
||||
public final class ArchiveImportDAO {
|
||||
private static volatile ArchiveImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArchiveImportDAO() {}
|
||||
|
||||
public static ArchiveImportDAO getInstance() {
|
||||
if (instance == null) synchronized (ArchiveImportDAO.class) {
|
||||
if (instance == null) instance = new ArchiveImportDAO();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает только доверенных publisher-ов, для которых обычный Solana users sync
|
||||
* уже увидел archive head, но этот head ещё не был полностью импортирован локально.
|
||||
*/
|
||||
public List<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
|
||||
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
|
||||
List<ArchivePublisherHead> out = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT login, root_key, archive_head_tx_id, archive_head_hash,
|
||||
archive_imported, archive_last_imported_tx_id
|
||||
FROM solana_user_pda_current
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND is_server=TRUE
|
||||
AND archive_head_tx_id<>''
|
||||
AND archive_head_hash<>''
|
||||
AND archive_imported=FALSE
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (String login : approvedLogins) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
out.add(new ArchivePublisherHead(
|
||||
rs.getString("login"),
|
||||
rs.getString("root_key"),
|
||||
rs.getString("archive_head_tx_id"),
|
||||
rs.getString("archive_head_hash"),
|
||||
rs.getBoolean("archive_imported"),
|
||||
rs.getString("archive_last_imported_tx_id")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сдвигает локальный курсор импорта внутри FULL archive chain.
|
||||
* Update выполняется только если текущий head всё ещё тот же, с которым работает importer.
|
||||
*/
|
||||
public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE solana_user_pda_current
|
||||
SET archive_last_imported_tx_id=?
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND archive_head_tx_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, importedTxId == null ? "" : importedTxId);
|
||||
ps.setString(2, publisherLogin);
|
||||
ps.setString(3, expectedHeadTxId);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает именно текущий head как полностью импортированный.
|
||||
* Если Solana users sync успел заменить head на более новый, update не произойдёт.
|
||||
*/
|
||||
public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE solana_user_pda_current
|
||||
SET archive_imported=TRUE,
|
||||
archive_last_imported_tx_id=archive_head_tx_id
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND archive_head_tx_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, publisherLogin);
|
||||
ps.setString(2, expectedHeadTxId);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException {
|
||||
String sql="""
|
||||
INSERT INTO archive_blockchain_location(
|
||||
blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
|
||||
chunk_offset,chunk_size,source_last_block_number,updated_at_ms)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(blockchain_name) DO UPDATE SET
|
||||
publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id,
|
||||
archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number,
|
||||
chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size,
|
||||
source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms
|
||||
WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId());
|
||||
ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset());
|
||||
ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException {
|
||||
String sql="""
|
||||
SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
|
||||
chunk_offset,chunk_size,source_last_block_number,updated_at_ms
|
||||
FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?)
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,blockchainName);
|
||||
try(ResultSet rs=ps.executeQuery()) {
|
||||
if(!rs.next()) return null;
|
||||
return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4),
|
||||
rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */
|
||||
public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException {
|
||||
String sql="""
|
||||
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
||||
FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,blockchainName);
|
||||
try(ResultSet rs=ps.executeQuery()) {
|
||||
if(!rs.next()) return null;
|
||||
return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.archive.ArchivePublishJob;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
|
||||
*/
|
||||
public final class ArchivePublicationDAO {
|
||||
private static volatile ArchivePublicationDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArchivePublicationDAO() {}
|
||||
|
||||
public static ArchivePublicationDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArchivePublicationDAO.class) {
|
||||
if (instance == null) instance = new ArchivePublicationDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getCursor(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
|
||||
last_archive_big_block_number, last_archive_big_block_hash,
|
||||
last_chunk_offset, last_chunk_size, updated_at_ms
|
||||
FROM archive_chain_cursor
|
||||
WHERE blockchain_name = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapCursor(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long nextBigBlockNumber() throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(MAX(big_block_number), 0) + 1
|
||||
FROM archive_publish_job
|
||||
WHERE status = 'CURSORS_COMMITTED'
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
|
||||
String sql = """
|
||||
SELECT big_block_number, archive_hash, arweave_tx_id
|
||||
FROM archive_publish_job
|
||||
WHERE status = 'CURSORS_COMMITTED'
|
||||
AND archive_hash IS NOT NULL
|
||||
AND arweave_tx_id IS NOT NULL
|
||||
ORDER BY big_block_number ASC
|
||||
""";
|
||||
List<ArchiveBigBlockRef> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new ArchiveBigBlockRef(
|
||||
rs.getLong("big_block_number"),
|
||||
rs.getBytes("archive_hash"),
|
||||
rs.getBytes("arweave_tx_id")
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public ArchivePublishJob findUnfinishedJob() throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job
|
||||
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapJob(rs) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public ArchivePublishJob getJob(long id) throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job
|
||||
WHERE id = ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapJob(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
|
||||
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
|
||||
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
long jobId;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
|
||||
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
ps.setLong(1, bigBlockNumber);
|
||||
ps.setLong(2, createdAtMs);
|
||||
ps.setLong(3, createdAtMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
|
||||
jobId = rs.getLong(1);
|
||||
}
|
||||
}
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job_chain(
|
||||
job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
bindJobChain(ps, new ArchivePublishJobChain(
|
||||
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
|
||||
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
|
||||
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
|
||||
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
c.commit();
|
||||
return jobId;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("Не удалось создать frozen archive job", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
|
||||
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
|
||||
RETURNING id
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, bigBlockNumber);
|
||||
ps.setLong(2, createdAtMs);
|
||||
ps.setLong(3, createdAtMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job_chain(
|
||||
job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindJobChain(ps, e);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
FROM archive_publish_job_chain
|
||||
WHERE job_id = ?
|
||||
ORDER BY blockchain_name ASC
|
||||
""";
|
||||
List<ArchivePublishJobChain> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, jobId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapJobChain(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE archive_publish_job_chain
|
||||
SET new_chunk_offset=?, new_chunk_size=?
|
||||
WHERE job_id=? AND blockchain_name=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, offset);
|
||||
ps.setLong(2, size);
|
||||
ps.setLong(3, jobId);
|
||||
ps.setString(4, blockchainName);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
|
||||
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
|
||||
ps.setString(1, localPath);
|
||||
ps.setBytes(2, hash);
|
||||
});
|
||||
}
|
||||
|
||||
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
|
||||
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
|
||||
}
|
||||
|
||||
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
|
||||
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
|
||||
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
|
||||
}
|
||||
|
||||
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
|
||||
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
|
||||
}
|
||||
|
||||
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
|
||||
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
|
||||
}
|
||||
|
||||
public void markSolanaFinalized(long jobId) throws SQLException {
|
||||
updateJob(jobId, "SOLANA_FINALIZED", null, null);
|
||||
}
|
||||
|
||||
public void markFailed(long jobId, String error) throws SQLException {
|
||||
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
|
||||
}
|
||||
|
||||
/**
|
||||
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
|
||||
*/
|
||||
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ArchivePublishJob job = getJob(c, jobId);
|
||||
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
|
||||
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
|
||||
long now = System.currentTimeMillis();
|
||||
String upsert = """
|
||||
INSERT INTO archive_chain_cursor(
|
||||
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
|
||||
last_archive_big_block_number, last_archive_big_block_hash,
|
||||
last_chunk_offset, last_chunk_size, updated_at_ms
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(blockchain_name) DO UPDATE SET
|
||||
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
|
||||
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
|
||||
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
|
||||
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
|
||||
last_chunk_offset=EXCLUDED.last_chunk_offset,
|
||||
last_chunk_size=EXCLUDED.last_chunk_size,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(upsert)) {
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
|
||||
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
|
||||
}
|
||||
ps.setString(1, ch.blockchainName());
|
||||
ps.setLong(2, ch.toSourceBlockNumber());
|
||||
ps.setBytes(3, ch.lastSourceBlockHash());
|
||||
ps.setLong(4, job.bigBlockNumber());
|
||||
ps.setBytes(5, bigBlockHash);
|
||||
ps.setLong(6, ch.newChunkOffset());
|
||||
ps.setLong(7, ch.newChunkSize());
|
||||
ps.setLong(8, now);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
|
||||
ps.setLong(1, now);
|
||||
ps.setLong(2, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("Не удалось commit archive cursors", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job WHERE id=?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
|
||||
}
|
||||
}
|
||||
|
||||
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
FROM archive_publish_job_chain
|
||||
WHERE job_id=? ORDER BY blockchain_name ASC
|
||||
""";
|
||||
List<ArchivePublishJobChain> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, jobId);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
|
||||
|
||||
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
|
||||
if (setFragment != null && !setFragment.isBlank()) {
|
||||
try (Connection c = db.getConnection()) {
|
||||
updateJobWithExtra(c, jobId, status, setFragment, binder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String sql = status == null
|
||||
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
|
||||
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
if (status != null) ps.setString(i++, status);
|
||||
ps.setLong(i++, System.currentTimeMillis());
|
||||
ps.setLong(i, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
|
||||
String statusSql = status == null ? "" : ", status=?";
|
||||
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
binder.bind(ps);
|
||||
int count = countParameters(setFragment);
|
||||
int i = count + 1;
|
||||
if (status != null) ps.setString(i++, status);
|
||||
ps.setLong(i++, System.currentTimeMillis());
|
||||
ps.setLong(i, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static int countParameters(String sqlPart) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
|
||||
int i=1;
|
||||
ps.setLong(i++, e.jobId());
|
||||
ps.setString(i++, e.blockchainName());
|
||||
ps.setLong(i++, e.fromSourceBlockNumber());
|
||||
ps.setLong(i++, e.toSourceBlockNumber());
|
||||
ps.setBytes(i++, e.previousSourceBlockHash());
|
||||
ps.setBytes(i++, e.lastSourceBlockHash());
|
||||
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
|
||||
ps.setBytes(i++, e.previousArchiveBigBlockHash());
|
||||
setLongNullable(ps, i++, e.previousChunkOffset());
|
||||
setLongNullable(ps, i++, e.previousChunkSize());
|
||||
setLongNullable(ps, i++, e.newChunkOffset());
|
||||
setLongNullable(ps, i, e.newChunkSize());
|
||||
}
|
||||
|
||||
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
|
||||
return new ArchiveChainCursor(
|
||||
rs.getString("blockchain_name"),
|
||||
rs.getLong("last_archived_source_block_number"),
|
||||
rs.getBytes("last_archived_source_block_hash"),
|
||||
rs.getLong("last_archive_big_block_number"),
|
||||
rs.getBytes("last_archive_big_block_hash"),
|
||||
rs.getLong("last_chunk_offset"),
|
||||
rs.getLong("last_chunk_size"),
|
||||
rs.getLong("updated_at_ms")
|
||||
);
|
||||
}
|
||||
|
||||
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
|
||||
int conf = rs.getInt("arweave_confirmations");
|
||||
Integer confObj = rs.wasNull() ? null : conf;
|
||||
return new ArchivePublishJob(
|
||||
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
|
||||
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
|
||||
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
|
||||
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
|
||||
);
|
||||
}
|
||||
|
||||
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
|
||||
return new ArchivePublishJobChain(
|
||||
rs.getLong("job_id"), rs.getString("blockchain_name"),
|
||||
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
|
||||
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
|
||||
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
|
||||
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
|
||||
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
|
||||
);
|
||||
}
|
||||
|
||||
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
|
||||
long v = rs.getLong(name);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
|
||||
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
|
||||
}
|
||||
}
|
||||
+127
-12
@@ -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() {}
|
||||
|
||||
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
|
||||
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||
|
||||
rebuildStatsState(c);
|
||||
|
||||
c.commit();
|
||||
|
||||
return new CleanupResult(
|
||||
@@ -185,10 +187,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 +200,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 +239,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 +309,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 +319,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);
|
||||
}
|
||||
|
||||
@@ -366,6 +368,119 @@ public final class BlockchainResyncCleanupDAO {
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
private void rebuildStatsState(Connection c) throws SQLException {
|
||||
try (PreparedStatement truncate = c.prepareStatement("""
|
||||
TRUNCATE TABLE user_stats_state, channel_stats_state
|
||||
""")) {
|
||||
truncate.executeUpdate();
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
u.login,
|
||||
COALESCE(own.owned_public_channels_count, 0),
|
||||
COALESCE(fu.following_users_count, 0),
|
||||
COALESCE(fc.following_channels_count, 0),
|
||||
COALESCE(cf.close_friends_count, 0),
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
LEFT JOIN (
|
||||
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
|
||||
FROM channel_names_state
|
||||
WHERE channel_type_code = 1
|
||||
GROUP BY owner_login
|
||||
) own ON LOWER(own.owner_login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT login, COUNT(*)::INTEGER AS following_users_count
|
||||
FROM connections_state
|
||||
WHERE rel_type = 30
|
||||
AND to_block_number = 0
|
||||
GROUP BY login
|
||||
) fu ON LOWER(fu.login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.channel_type_code = 1
|
||||
GROUP BY cs.login
|
||||
) fc ON LOWER(fc.login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT login, COUNT(*)::INTEGER AS close_friends_count
|
||||
FROM connections_state
|
||||
WHERE rel_type = 10
|
||||
GROUP BY login
|
||||
) cf ON LOWER(cf.login) = LOWER(u.login)
|
||||
""")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
|
||||
""")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
cn.owner_bch_name,
|
||||
cn.channel_root_block_number,
|
||||
cn.channel_root_block_hash,
|
||||
cn.owner_login,
|
||||
cn.channel_type_code,
|
||||
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM channel_names_state cn
|
||||
LEFT JOIN connections_state cs
|
||||
ON cs.rel_type = 30
|
||||
AND cs.to_bch_name = cn.owner_bch_name
|
||||
AND cs.to_block_number = cn.channel_root_block_number
|
||||
AND cs.to_block_hash = cn.channel_root_block_hash
|
||||
WHERE cn.channel_type_code = 1
|
||||
GROUP BY
|
||||
cn.owner_bch_name,
|
||||
cn.channel_root_block_number,
|
||||
cn.channel_root_block_hash,
|
||||
cn.owner_login,
|
||||
cn.channel_type_code
|
||||
""")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, value);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Server projection for per-user channel read/unread state.
|
||||
*
|
||||
* Important rules:
|
||||
* - rows are created only by a signed client SetChannelReadState;
|
||||
* - channel publications increment unread_count only for rows that already exist;
|
||||
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
|
||||
* - unread_count is never part of the client signature and is server-owned.
|
||||
*/
|
||||
public final class ChannelReadStateDAO {
|
||||
private static volatile ChannelReadStateDAO instance;
|
||||
|
||||
private ChannelReadStateDAO() {}
|
||||
|
||||
public static ChannelReadStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ChannelReadStateDAO.class) {
|
||||
if (instance == null) instance = new ChannelReadStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
|
||||
String clientKey, String readSignature) {}
|
||||
|
||||
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
|
||||
|
||||
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
|
||||
FROM channel_read_state
|
||||
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new ReadState(
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")),
|
||||
rs.getLong("read_updated_at_ms"),
|
||||
rs.getString("client_key"),
|
||||
rs.getString("read_signature"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return state == null ? 0L : state.unreadCount();
|
||||
}
|
||||
|
||||
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(SUM(crs.unread_count),0)
|
||||
FROM channel_read_state crs
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=30
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the requested public channel exists and that viewer currently follows it.
|
||||
*/
|
||||
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
|
||||
int followRelType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(?)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=?
|
||||
AND cns.slug=?
|
||||
AND cns.channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, followRelType);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT slug
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("slug") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT channel_root_block_number
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getInt("channel_root_block_number") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("owner_login") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
|
||||
* Older timestamps and backwards read_count values are ignored.
|
||||
*/
|
||||
public UpsertResult upsertSignedReadIfNewer(Connection c,
|
||||
String viewerLogin,
|
||||
String ownerLogin,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
long readCount,
|
||||
long readUpdatedAtMs,
|
||||
String clientKey,
|
||||
String readSignature,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO channel_read_state (
|
||||
viewer_login, owner_login, owner_bch_name, channel_name,
|
||||
read_count, unread_count,
|
||||
read_updated_at_ms, client_key, read_signature, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
|
||||
DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
unread_count = GREATEST(
|
||||
0,
|
||||
channel_read_state.unread_count -
|
||||
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
|
||||
),
|
||||
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
|
||||
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
|
||||
client_key = EXCLUDED.client_key,
|
||||
read_signature = EXCLUDED.read_signature,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
|
||||
AND EXCLUDED.read_count >= channel_read_state.read_count
|
||||
RETURNING read_count, unread_count
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
ps.setLong(5, Math.max(0L, readCount));
|
||||
ps.setLong(6, readUpdatedAtMs);
|
||||
ps.setString(7, clientKey);
|
||||
ps.setString(8, readSignature);
|
||||
ps.setLong(9, nowMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new UpsertResult(true,
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")));
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return new UpsertResult(false,
|
||||
current == null ? 0L : current.readCount(),
|
||||
current == null ? 0L : current.unreadCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments existing rows for current subscribers of one public channel.
|
||||
* Missing channel_read_state rows are deliberately NOT created.
|
||||
* Returns affected viewer logins for post-commit counter pushes.
|
||||
*/
|
||||
public List<String> incrementUnreadForSubscribers(Connection c,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
int followRelType,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE channel_read_state crs
|
||||
SET unread_count = crs.unread_count + 1,
|
||||
updated_at_ms = ?
|
||||
WHERE crs.owner_bch_name = ?
|
||||
AND crs.channel_name = ?
|
||||
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
RETURNING viewer_login
|
||||
""";
|
||||
List<String> affected = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
ps.setInt(4, followRelType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) affected.add(rs.getString("viewer_login"));
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
|
||||
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
|
||||
int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM channel_read_state crs
|
||||
USING channel_names_state cns
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND crs.owner_bch_name=?
|
||||
AND cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_root_block_number=?
|
||||
AND cns.channel_type_code=1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setInt(3, rootBlockNumber);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
@@ -65,6 +67,36 @@ public final class ConnectionsStateDAO {
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM connections_state cs
|
||||
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 = ?
|
||||
AND (
|
||||
LOWER(cs.to_login) = LOWER(?)
|
||||
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
|
||||
)
|
||||
LIMIT 1
|
||||
""".formatted(
|
||||
CurrentUsersSql.usersSubquery("u_login"),
|
||||
CurrentUsersSql.usersSubquery("u_bch")
|
||||
);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setInt(2, relType);
|
||||
ps.setString(3, peerLoginAnyCase);
|
||||
ps.setString(4, peerLoginAnyCase);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
||||
*/
|
||||
@@ -72,17 +104,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 +141,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 +176,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
|
||||
|
||||
+61
-67
@@ -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,518 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DmDeliveryStateEntry;
|
||||
|
||||
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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Хранилище изменяемого состояния межсерверной доставки DM-пары. */
|
||||
public final class DmDeliveryStateDAO {
|
||||
private static volatile DmDeliveryStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DmDeliveryStateDAO() {}
|
||||
|
||||
public static DmDeliveryStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmDeliveryStateDAO.class) {
|
||||
if (instance == null) instance = new DmDeliveryStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry upsertPair(
|
||||
String outgoingMessageKey,
|
||||
String eventId,
|
||||
String baseKey,
|
||||
String fromLogin,
|
||||
String toLogin,
|
||||
String incomingMessageKey,
|
||||
long createdAtMs,
|
||||
long expiresAtMs,
|
||||
int initialState,
|
||||
String deliveredServerLogin,
|
||||
String routesHash,
|
||||
boolean assistImmediately
|
||||
) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
int safeState = normalizeState(initialState, deliveredServerLogin);
|
||||
if (expiresAtMs <= now && safeState == DmDeliveryStateEntry.PENDING_NONE) {
|
||||
safeState = DmDeliveryStateEntry.FAILED_FINAL;
|
||||
}
|
||||
Long nextAttemptAt = safeState == DmDeliveryStateEntry.ACCEPTED
|
||||
? now
|
||||
: null;
|
||||
String safeDeliveredLogin = safeState == DmDeliveryStateEntry.DELIVERED_ONE
|
||||
? normalize(deliveredServerLogin)
|
||||
: null;
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO dm_delivery_state (
|
||||
outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL, ?)
|
||||
ON CONFLICT (outgoing_message_key) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
base_key = EXCLUDED.base_key,
|
||||
from_login = EXCLUDED.from_login,
|
||||
to_login = EXCLUDED.to_login,
|
||||
incoming_message_key = EXCLUDED.incoming_message_key,
|
||||
created_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.created_at_ms
|
||||
ELSE EXCLUDED.created_at_ms
|
||||
END,
|
||||
delivery_expires_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_expires_at_ms
|
||||
ELSE EXCLUDED.delivery_expires_at_ms
|
||||
END,
|
||||
delivery_state = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_state
|
||||
ELSE EXCLUDED.delivery_state
|
||||
END,
|
||||
delivered_server_login = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivered_server_login
|
||||
ELSE EXCLUDED.delivered_server_login
|
||||
END,
|
||||
recipient_routes_hash = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN COALESCE(dm_delivery_state.recipient_routes_hash, EXCLUDED.recipient_routes_hash)
|
||||
ELSE EXCLUDED.recipient_routes_hash
|
||||
END,
|
||||
attempt_index = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.attempt_index
|
||||
ELSE 0
|
||||
END,
|
||||
next_attempt_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.next_attempt_at_ms
|
||||
ELSE EXCLUDED.next_attempt_at_ms
|
||||
END,
|
||||
last_attempt_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_attempt_at_ms
|
||||
ELSE NULL
|
||||
END,
|
||||
last_error = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_error
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, outgoingMessageKey);
|
||||
ps.setString(2, eventId);
|
||||
ps.setString(3, baseKey);
|
||||
ps.setString(4, normalize(fromLogin));
|
||||
ps.setString(5, normalize(toLogin));
|
||||
ps.setString(6, incomingMessageKey);
|
||||
ps.setLong(7, createdAtMs);
|
||||
ps.setLong(8, expiresAtMs);
|
||||
ps.setInt(9, safeState);
|
||||
setNullableString(ps, 10, safeDeliveredLogin);
|
||||
setNullableString(ps, 11, normalizeBlank(routesHash));
|
||||
setNullableLong(ps, 12, nextAttemptAt);
|
||||
ps.setLong(13, now);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
DmDeliveryStateEntry result;
|
||||
if (initialState != DmDeliveryStateEntry.PENDING_NONE || !isBlank(deliveredServerLogin)) {
|
||||
result = mergeRemote(eventId, initialState, deliveredServerLogin, routesHash, now);
|
||||
} else {
|
||||
result = getByEventId(eventId);
|
||||
}
|
||||
if (result != null && result.getDeliveryExpiresAtMs() <= now
|
||||
&& result.getDeliveryState() == DmDeliveryStateEntry.PENDING_NONE) {
|
||||
return finishAtExpiry(eventId, now);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry getByEventId(String eventId) throws SQLException {
|
||||
if (isBlank(eventId)) return null;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ?")) {
|
||||
ps.setString(1, eventId.trim());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry getByOutgoingMessageKey(String outgoingMessageKey) throws SQLException {
|
||||
if (isBlank(outgoingMessageKey)) return null;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE outgoing_message_key = ?")) {
|
||||
ps.setString(1, outgoingMessageKey.trim());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, DmDeliveryStateEntry> listByOutgoingMessageKeys(List<String> keys) throws SQLException {
|
||||
Map<String, DmDeliveryStateEntry> out = new HashMap<>();
|
||||
if (keys == null || keys.isEmpty()) return out;
|
||||
String placeholders = String.join(",", java.util.Collections.nCopies(keys.size(), "?"));
|
||||
String sql = selectColumns() + " WHERE outgoing_message_key IN (" + placeholders + ")";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (int i = 0; i < keys.size(); i++) ps.setString(i + 1, keys.get(i));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
DmDeliveryStateEntry row = mapRow(rs);
|
||||
out.put(row.getOutgoingMessageKey(), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<DmDeliveryStateEntry> listDue(long nowMs, int limit) throws SQLException {
|
||||
String sql = selectColumns() + """
|
||||
WHERE delivery_state = 0
|
||||
AND next_attempt_at_ms IS NOT NULL
|
||||
AND next_attempt_at_ms <= ?
|
||||
ORDER BY next_attempt_at_ms ASC, created_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<DmDeliveryStateEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setInt(2, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean claimAttempt(DmDeliveryStateEntry row, long nowMs, Long nextAttemptAtMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_delivery_state
|
||||
SET attempt_index = attempt_index + 1,
|
||||
next_attempt_at_ms = ?,
|
||||
last_attempt_at_ms = ?,
|
||||
last_error = NULL,
|
||||
updated_at_ms = ?
|
||||
WHERE outgoing_message_key = ?
|
||||
AND event_id = ?
|
||||
AND attempt_index = ?
|
||||
AND delivery_state = 0
|
||||
AND next_attempt_at_ms IS NOT NULL
|
||||
AND next_attempt_at_ms <= ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long networkLeaseUntilMs = nowMs + 60_000L;
|
||||
Long claimedNextAttemptAtMs = nextAttemptAtMs == null
|
||||
? networkLeaseUntilMs
|
||||
: Math.max(nextAttemptAtMs, networkLeaseUntilMs);
|
||||
setNullableLong(ps, 1, claimedNextAttemptAtMs);
|
||||
ps.setLong(2, nowMs);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.setString(4, row.getOutgoingMessageKey());
|
||||
ps.setString(5, row.getEventId());
|
||||
ps.setInt(6, row.getAttemptIndex());
|
||||
ps.setLong(7, nowMs);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry mergeRemote(
|
||||
String eventId,
|
||||
int remoteState,
|
||||
String remoteDeliveredLogin,
|
||||
String remoteRoutesHash,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean previousAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
|
||||
if (current == null) {
|
||||
c.rollback();
|
||||
return null;
|
||||
}
|
||||
MergeResult merged = merge(
|
||||
current.getDeliveryState(),
|
||||
current.getDeliveredServerLogin(),
|
||||
current.getRecipientRoutesHash(),
|
||||
remoteState,
|
||||
remoteDeliveredLogin,
|
||||
remoteRoutesHash
|
||||
);
|
||||
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
|
||||
merged.routesHash(), current.getNextAttemptAtMs(), current.getLastError(), nowMs);
|
||||
c.commit();
|
||||
return getByEventId(eventId);
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw e;
|
||||
} finally {
|
||||
c.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry updateAfterAttempt(
|
||||
String eventId,
|
||||
int attemptedState,
|
||||
String deliveredServerLogin,
|
||||
String routesHash,
|
||||
Long nextAttemptAtMs,
|
||||
String lastError,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean previousAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
|
||||
if (current == null) {
|
||||
c.rollback();
|
||||
return null;
|
||||
}
|
||||
MergeResult merged = merge(
|
||||
current.getDeliveryState(), current.getDeliveredServerLogin(), current.getRecipientRoutesHash(),
|
||||
attemptedState, deliveredServerLogin, routesHash
|
||||
);
|
||||
Long effectiveNext = merged.state() == DmDeliveryStateEntry.ACCEPTED
|
||||
? nextAttemptAtMs
|
||||
: null;
|
||||
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
|
||||
merged.routesHash(), effectiveNext, lastError, nowMs);
|
||||
c.commit();
|
||||
return getByEventId(eventId);
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw e;
|
||||
} finally {
|
||||
c.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry finishAtExpiry(String eventId, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = CASE WHEN delivery_state = 0 THEN 3 ELSE delivery_state END,
|
||||
delivered_server_login = CASE WHEN delivery_state = 0 THEN NULL ELSE delivered_server_login END,
|
||||
next_attempt_at_ms = NULL,
|
||||
last_error = CASE WHEN delivery_state = 0 THEN 'DELIVERY_EXPIRED' ELSE last_error END,
|
||||
updated_at_ms = ?
|
||||
WHERE event_id = ? AND delivery_state = 0
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, eventId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
return getByEventId(eventId);
|
||||
}
|
||||
|
||||
/** Read-only peer confirmed that at least one recipient server accepted the message. */
|
||||
public DmDeliveryStateEntry markDeliveredFromPeer(String eventId, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = 1,
|
||||
delivered_server_login = NULL,
|
||||
next_attempt_at_ms = NULL,
|
||||
last_error = NULL,
|
||||
updated_at_ms = ?
|
||||
WHERE event_id = ? AND delivery_state = 0
|
||||
""")) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, eventId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return getByEventId(eventId);
|
||||
}
|
||||
|
||||
public int removeByBaseKey(String baseKey) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("DELETE FROM dm_delivery_state WHERE base_key = ?")) {
|
||||
ps.setString(1, baseKey);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeConversationBefore(String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM dm_delivery_state
|
||||
WHERE created_at_ms < ?
|
||||
AND ((LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)))
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, boundaryTimeMs);
|
||||
ps.setString(2, fromLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toLogin);
|
||||
ps.setString(5, fromLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeMissingMessages() throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM dm_delivery_state d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM signed_messages m WHERE m.message_key = d.outgoing_message_key
|
||||
)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private DmDeliveryStateEntry getByEventIdForUpdate(Connection c, String eventId) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ? FOR UPDATE")) {
|
||||
ps.setString(1, eventId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateMutableState(
|
||||
Connection c,
|
||||
DmDeliveryStateEntry current,
|
||||
int state,
|
||||
String deliveredLogin,
|
||||
String routesHash,
|
||||
Long nextAttemptAtMs,
|
||||
String lastError,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = ?,
|
||||
delivered_server_login = ?,
|
||||
recipient_routes_hash = ?,
|
||||
next_attempt_at_ms = ?,
|
||||
last_error = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE outgoing_message_key = ? AND event_id = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, state);
|
||||
setNullableString(ps, 2, state == DmDeliveryStateEntry.DELIVERED_ONE ? normalize(deliveredLogin) : null);
|
||||
setNullableString(ps, 3, normalizeBlank(routesHash));
|
||||
setNullableLong(ps, 4, nextAttemptAtMs);
|
||||
setNullableString(ps, 5, normalizeBlank(lastError));
|
||||
ps.setLong(6, nowMs);
|
||||
ps.setString(7, current.getOutgoingMessageKey());
|
||||
ps.setString(8, current.getEventId());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private MergeResult merge(
|
||||
int localState,
|
||||
String localLogin,
|
||||
String localHash,
|
||||
int remoteStateRaw,
|
||||
String remoteLoginRaw,
|
||||
String remoteHashRaw
|
||||
) {
|
||||
int remoteState = normalizeState(remoteStateRaw, remoteLoginRaw);
|
||||
String remoteLogin = normalize(remoteLoginRaw);
|
||||
String localNormalizedLogin = normalize(localLogin);
|
||||
String localRoutesHash = normalizeBlank(localHash);
|
||||
String remoteRoutesHash = normalizeBlank(remoteHashRaw);
|
||||
String resultHash = remoteRoutesHash != null ? remoteRoutesHash : localRoutesHash;
|
||||
|
||||
if (localState == DmDeliveryStateEntry.DELIVERED_ONE || localState == DmDeliveryStateEntry.DELIVERED_ALL) {
|
||||
return new MergeResult(DmDeliveryStateEntry.DELIVERED, localNormalizedLogin,
|
||||
localRoutesHash != null ? localRoutesHash : remoteRoutesHash);
|
||||
}
|
||||
if (remoteState == DmDeliveryStateEntry.DELIVERED_ONE || remoteState == DmDeliveryStateEntry.DELIVERED_ALL) {
|
||||
return new MergeResult(DmDeliveryStateEntry.DELIVERED, remoteLogin, resultHash);
|
||||
}
|
||||
if (localState == DmDeliveryStateEntry.FAILED_FINAL || remoteState == DmDeliveryStateEntry.FAILED_FINAL) {
|
||||
return new MergeResult(DmDeliveryStateEntry.FAILED_FINAL, null, resultHash);
|
||||
}
|
||||
return new MergeResult(DmDeliveryStateEntry.PENDING_NONE, null, resultHash);
|
||||
}
|
||||
|
||||
private int normalizeState(int state, String deliveredLogin) {
|
||||
if (state < DmDeliveryStateEntry.PENDING_NONE || state > DmDeliveryStateEntry.FAILED_FINAL) {
|
||||
return DmDeliveryStateEntry.PENDING_NONE;
|
||||
}
|
||||
if (state == DmDeliveryStateEntry.DELIVERED_ALL) return DmDeliveryStateEntry.DELIVERED;
|
||||
return state;
|
||||
}
|
||||
|
||||
private String selectColumns() {
|
||||
return """
|
||||
SELECT outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||
FROM dm_delivery_state
|
||||
""";
|
||||
}
|
||||
|
||||
private DmDeliveryStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
DmDeliveryStateEntry e = new DmDeliveryStateEntry();
|
||||
e.setOutgoingMessageKey(rs.getString("outgoing_message_key"));
|
||||
e.setEventId(rs.getString("event_id"));
|
||||
e.setBaseKey(rs.getString("base_key"));
|
||||
e.setFromLogin(rs.getString("from_login"));
|
||||
e.setToLogin(rs.getString("to_login"));
|
||||
e.setIncomingMessageKey(rs.getString("incoming_message_key"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setDeliveryExpiresAtMs(rs.getLong("delivery_expires_at_ms"));
|
||||
e.setDeliveryState(rs.getInt("delivery_state"));
|
||||
e.setDeliveredServerLogin(rs.getString("delivered_server_login"));
|
||||
e.setRecipientRoutesHash(rs.getString("recipient_routes_hash"));
|
||||
e.setAttemptIndex(rs.getInt("attempt_index"));
|
||||
long next = rs.getLong("next_attempt_at_ms");
|
||||
e.setNextAttemptAtMs(rs.wasNull() ? null : next);
|
||||
long last = rs.getLong("last_attempt_at_ms");
|
||||
e.setLastAttemptAtMs(rs.wasNull() ? null : last);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException {
|
||||
if (isBlank(value)) ps.setNull(index, Types.VARCHAR);
|
||||
else ps.setString(index, value.trim());
|
||||
}
|
||||
|
||||
private static void setNullableLong(PreparedStatement ps, int index, Long value) throws SQLException {
|
||||
if (value == null) ps.setNull(index, Types.BIGINT);
|
||||
else ps.setLong(index, value);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim().toLowerCase();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static String normalizeBlank(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record MergeResult(int state, String deliveredLogin, String routesHash) {}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
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.Base64;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public final class DmDialogStateDAO {
|
||||
private static volatile DmDialogStateDAO instance;
|
||||
|
||||
private DmDialogStateDAO() {}
|
||||
|
||||
public static DmDialogStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmDialogStateDAO.class) {
|
||||
if (instance == null) instance = new DmDialogStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void bootstrapIfEmpty(Connection c) throws SQLException {
|
||||
if (c == null) return;
|
||||
if (hasAnyRow(c)) return;
|
||||
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
for (PairItem pair : listConversationPairs(c)) {
|
||||
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
|
||||
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||
refreshConversation(c, cleanOwner, cleanPeer);
|
||||
refreshConversation(c, cleanPeer, cleanOwner);
|
||||
}
|
||||
|
||||
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
if (cleanOwner.isEmpty()) return List.of();
|
||||
|
||||
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||
last_message_time_ms, unread_count, last_read_receipt_time_ms,
|
||||
updated_at_ms
|
||||
FROM dm_dialog_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
""")) {
|
||||
ps.setString(1, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||
int unreadCount = rs.getInt("unread_count");
|
||||
DialogSummary row = new DialogSummary(
|
||||
rs.getString("owner_login"),
|
||||
rs.getString("peer_login"),
|
||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||
rs.getString("last_message_blob_b64"),
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
rs.getLong("last_read_receipt_time_ms"),
|
||||
rs.getLong("updated_at_ms"),
|
||||
lastMessageTimeMs > 0 || unreadCount > 0
|
||||
);
|
||||
byPeer.put(normKey(row.peerLogin()), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String peer : contacts) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||
}
|
||||
for (String peer : friends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
|
||||
}
|
||||
for (String peer : closeFriends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||
}
|
||||
|
||||
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
|
||||
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
|
||||
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
|
||||
row.ownerLogin(),
|
||||
row.peerLogin(),
|
||||
relationFlag,
|
||||
row.lastMessageBlobB64(),
|
||||
row.lastMessageTimeMs(),
|
||||
row.unreadCount(),
|
||||
row.lastReadReceiptTimeMs(),
|
||||
row.updatedAtMs(),
|
||||
row.hasDialog()
|
||||
));
|
||||
}
|
||||
|
||||
List<DialogSummary> out = new ArrayList<>(byPeer.values());
|
||||
out.sort((a, b) -> {
|
||||
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
|
||||
if (cmp != 0) return cmp;
|
||||
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||
if (entry == null) return;
|
||||
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
|
||||
}
|
||||
|
||||
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
|
||||
upsert(c, summary);
|
||||
if (summary.lastReadReceiptTimeMs() > 0) {
|
||||
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
|
||||
}
|
||||
}
|
||||
|
||||
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
|
||||
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
|
||||
}
|
||||
|
||||
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
|
||||
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
|
||||
|
||||
String latestSql = """
|
||||
SELECT raw_block, time_ms
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
String unreadSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM signed_messages
|
||||
WHERE LOWER(from_login) = LOWER(?)
|
||||
AND LOWER(to_login) = LOWER(?)
|
||||
AND message_type = 1
|
||||
AND time_ms > ?
|
||||
AND (read_at_ms IS NULL OR read_at_ms <= 0)
|
||||
""";
|
||||
String contentReadSql = """
|
||||
SELECT MAX(read_at_ms)
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
AND read_at_ms IS NOT NULL
|
||||
AND read_at_ms > 0
|
||||
""";
|
||||
String receiptWatermarkSql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (3, 4)
|
||||
""";
|
||||
|
||||
String lastMessageBlobB64 = "";
|
||||
long lastMessageTimeMs = 0L;
|
||||
int unreadCount = 0;
|
||||
long lastReadReceiptTimeMs = existingWatermark;
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
lastMessageTimeMs = rs.getLong("time_ms");
|
||||
byte[] rawBlock = rs.getBytes("raw_block");
|
||||
if (rawBlock != null && rawBlock.length > 0) {
|
||||
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong(1);
|
||||
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong(1);
|
||||
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
|
||||
ps.setString(1, cleanPeer);
|
||||
ps.setString(2, cleanOwner);
|
||||
ps.setLong(3, lastReadReceiptTimeMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) unreadCount = rs.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
return new DialogSummary(
|
||||
cleanOwner,
|
||||
cleanPeer,
|
||||
relationFlag,
|
||||
lastMessageBlobB64,
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
lastReadReceiptTimeMs,
|
||||
System.currentTimeMillis(),
|
||||
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
|
||||
);
|
||||
}
|
||||
|
||||
private void upsert(Connection c, DialogSummary summary) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_dialog_state (
|
||||
owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
|
||||
relation_flag = EXCLUDED.relation_flag,
|
||||
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
|
||||
last_message_time_ms = EXCLUDED.last_message_time_ms,
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, summary.ownerLogin());
|
||||
ps.setString(2, summary.peerLogin());
|
||||
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
|
||||
ps.setString(4, summary.lastMessageBlobB64());
|
||||
ps.setLong(5, summary.lastMessageTimeMs());
|
||||
ps.setInt(6, summary.unreadCount());
|
||||
ps.setLong(7, summary.lastReadReceiptTimeMs());
|
||||
ps.setLong(8, summary.updatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||
return "friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
private boolean hasAnyRow(Connection c) throws SQLException {
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
|
||||
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT owner_login, peer_login
|
||||
FROM (
|
||||
SELECT
|
||||
m.target_login AS owner_login,
|
||||
CASE
|
||||
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
|
||||
ELSE m.to_login
|
||||
END AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.from_login AS owner_login, m.to_login AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (5, 6, 7, 8)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.to_login AS owner_login, m.from_login AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (5, 6, 7, 8)
|
||||
) pairs
|
||||
WHERE owner_login IS NOT NULL
|
||||
AND peer_login IS NOT NULL
|
||||
AND BTRIM(owner_login) <> ''
|
||||
AND BTRIM(peer_login) <> ''
|
||||
AND LOWER(owner_login) <> LOWER(peer_login)
|
||||
ORDER BY owner_login, peer_login
|
||||
""";
|
||||
List<PairItem> out = new ArrayList<>();
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
String ownerLogin = rs.getString("owner_login");
|
||||
String peerLogin = rs.getString("peer_login");
|
||||
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
|
||||
out.add(new PairItem(ownerLogin, peerLogin));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
|
||||
String owner = normalize(ownerLogin);
|
||||
String peer = normalize(peerLogin);
|
||||
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
|
||||
String key = normKey(peer);
|
||||
DialogSummary current = map.get(key);
|
||||
if (current == null) {
|
||||
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
|
||||
return;
|
||||
}
|
||||
String nextRelation = current.relationFlag();
|
||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "close_friend";
|
||||
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "friend";
|
||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "contact";
|
||||
} else {
|
||||
nextRelation = normalizeRelationFlag(nextRelation);
|
||||
}
|
||||
map.put(key, new DialogSummary(
|
||||
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
|
||||
peer,
|
||||
nextRelation,
|
||||
current.lastMessageBlobB64(),
|
||||
current.lastMessageTimeMs(),
|
||||
current.unreadCount(),
|
||||
current.lastReadReceiptTimeMs(),
|
||||
current.updatedAtMs(),
|
||||
current.hasDialog() || hasDialog
|
||||
));
|
||||
}
|
||||
|
||||
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
|
||||
if (watermark <= 0) return;
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_messages
|
||||
SET read_at_ms = CASE
|
||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||
WHEN read_at_ms > ? THEN read_at_ms
|
||||
ELSE read_at_ms
|
||||
END
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
AND time_ms <= ?
|
||||
""")) {
|
||||
ps.setLong(1, watermark);
|
||||
ps.setLong(2, watermark);
|
||||
ps.setString(3, cleanOwner);
|
||||
ps.setString(4, cleanPeer);
|
||||
ps.setString(5, cleanPeer);
|
||||
ps.setString(6, cleanOwner);
|
||||
ps.setLong(7, watermark);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT last_read_receipt_time_ms
|
||||
FROM dm_dialog_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(peer_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, peerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return 0L;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? 0L : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private String normKey(String value) {
|
||||
return normalize(value).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeRelationFlag(String value) {
|
||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
return "none";
|
||||
}
|
||||
|
||||
public record DialogSummary(
|
||||
String ownerLogin,
|
||||
String peerLogin,
|
||||
String relationFlag,
|
||||
String lastMessageBlobB64,
|
||||
long lastMessageTimeMs,
|
||||
int unreadCount,
|
||||
long lastReadReceiptTimeMs,
|
||||
long updatedAtMs,
|
||||
boolean hasDialog
|
||||
) {}
|
||||
|
||||
private record PairItem(String ownerLogin, String peerLogin) {}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DmSyncOutboxEntry;
|
||||
|
||||
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;
|
||||
|
||||
/** Outbox событий DM для единственного второго access-сервера пользователя. */
|
||||
public final class DmSyncOutboxDAO {
|
||||
private static volatile DmSyncOutboxDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DmSyncOutboxDAO() {}
|
||||
|
||||
public static DmSyncOutboxDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmSyncOutboxDAO.class) {
|
||||
if (instance == null) instance = new DmSyncOutboxDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void upsert(
|
||||
String ownerLogin,
|
||||
String primaryMessageKey,
|
||||
String eventId,
|
||||
String secondaryMessageKey,
|
||||
boolean synced,
|
||||
long createdAtMs
|
||||
) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
String sql = """
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
) VALUES (LOWER(?), ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
secondary_message_key = EXCLUDED.secondary_message_key,
|
||||
synced = CASE
|
||||
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
|
||||
THEN dm_sync_outbox.synced OR EXCLUDED.synced
|
||||
ELSE EXCLUDED.synced
|
||||
END,
|
||||
created_at_ms = CASE
|
||||
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
|
||||
THEN dm_sync_outbox.created_at_ms
|
||||
ELSE EXCLUDED.created_at_ms
|
||||
END,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, primaryMessageKey);
|
||||
ps.setString(3, eventId);
|
||||
if (secondaryMessageKey == null || secondaryMessageKey.isBlank()) ps.setNull(4, Types.VARCHAR);
|
||||
else ps.setString(4, secondaryMessageKey.trim());
|
||||
ps.setBoolean(5, synced);
|
||||
ps.setLong(6, createdAtMs);
|
||||
ps.setLong(7, now);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<DmSyncOutboxEntry> listUnsynced(String ownerLogin, int limit) throws SQLException {
|
||||
return listUnsynced(ownerLogin, 0L, "", limit);
|
||||
}
|
||||
|
||||
public List<DmSyncOutboxEntry> listUnsynced(
|
||||
String ownerLogin, long afterCreatedAtMs, String afterPrimaryMessageKey, int limit
|
||||
) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
FROM dm_sync_outbox
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND synced = FALSE
|
||||
AND (created_at_ms > ? OR (created_at_ms = ? AND primary_message_key > ?))
|
||||
ORDER BY created_at_ms ASC, primary_message_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<DmSyncOutboxEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setLong(2, Math.max(0L, afterCreatedAtMs));
|
||||
ps.setLong(3, Math.max(0L, afterCreatedAtMs));
|
||||
ps.setString(4, afterPrimaryMessageKey == null ? "" : afterPrimaryMessageKey);
|
||||
ps.setInt(5, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean hasUnsyncedForServer(String serverLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM dm_sync_outbox o
|
||||
JOIN user_access_servers_current r
|
||||
ON LOWER(r.user_login) = LOWER(o.owner_login)
|
||||
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_access_servers_current peer
|
||||
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
|
||||
AND LOWER(peer.server_login) <> LOWER(?)
|
||||
)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, serverLogin);
|
||||
ps.setString(2, serverLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> listUnsyncedOwnersForServer(String serverLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT o.owner_login
|
||||
FROM dm_sync_outbox o
|
||||
JOIN user_access_servers_current r
|
||||
ON LOWER(r.user_login) = LOWER(o.owner_login)
|
||||
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_access_servers_current peer
|
||||
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
|
||||
AND LOWER(peer.server_login) <> LOWER(?)
|
||||
)
|
||||
ORDER BY o.owner_login
|
||||
""";
|
||||
List<String> result = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, serverLogin);
|
||||
ps.setString(2, serverLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(rs.getString("owner_login"));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public int markSynced(String ownerLogin, String eventId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_sync_outbox
|
||||
SET synced = TRUE, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, eventId);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markSynced(String ownerLogin, List<String> eventIds) throws SQLException {
|
||||
if (eventIds == null || eventIds.isEmpty()) return 0;
|
||||
int total = 0;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE dm_sync_outbox
|
||||
SET synced = TRUE, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
|
||||
""")) {
|
||||
long now = System.currentTimeMillis();
|
||||
for (String eventId : eventIds) {
|
||||
if (eventId == null || eventId.isBlank()) continue;
|
||||
ps.setLong(1, now);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, eventId.trim());
|
||||
ps.addBatch();
|
||||
}
|
||||
for (int changed : ps.executeBatch()) if (changed > 0) total += changed;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public int markAllUnsynced(String ownerLogin) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_sync_outbox
|
||||
SET synced = FALSE, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
ps.setString(2, ownerLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced() throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("UPDATE dm_sync_outbox SET synced = FALSE, updated_at_ms = ?")) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeMissingMessages() throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM dm_sync_outbox o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM signed_messages m WHERE m.message_key = o.primary_message_key
|
||||
)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private DmSyncOutboxEntry mapRow(ResultSet rs) throws SQLException {
|
||||
DmSyncOutboxEntry e = new DmSyncOutboxEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setPrimaryMessageKey(rs.getString("primary_message_key"));
|
||||
e.setEventId(rs.getString("event_id"));
|
||||
e.setSecondaryMessageKey(rs.getString("secondary_message_key"));
|
||||
e.setSynced(rs.getBoolean("synced"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
|
||||
|
||||
-47
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
-118
@@ -1,7 +1,7 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -11,9 +11,7 @@ import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class SignedMessagesV2DAO {
|
||||
private static final int SQLITE_BUSY_MAX_RETRIES = 6;
|
||||
private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L;
|
||||
public final class SignedMessagesDAO {
|
||||
|
||||
public enum ApplyStatus {
|
||||
APPLIED,
|
||||
@@ -26,39 +24,41 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile SignedMessagesV2DAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private static volatile SignedMessagesDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SignedMessagesV2DAO() {}
|
||||
private SignedMessagesDAO() {}
|
||||
|
||||
public static SignedMessagesV2DAO getInstance() {
|
||||
public static SignedMessagesDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedMessagesV2DAO.class) {
|
||||
if (instance == null) instance = new SignedMessagesV2DAO();
|
||||
synchronized (SignedMessagesDAO.class) {
|
||||
if (instance == null) instance = new SignedMessagesDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||
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 OR IGNORE INTO signed_messages_v2 (
|
||||
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);
|
||||
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||
public boolean insertPairBothOrNothing(SignedMessageEntry first, SignedMessageEntry second) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
@@ -77,6 +77,7 @@ public final class SignedMessagesV2DAO {
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
markMessageReadByReceipt(c, first);
|
||||
markMessageReadByReceipt(c, second);
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
@@ -95,7 +96,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
||||
public ApplyStatus upsertContentPair(SignedMessageEntry incoming, SignedMessageEntry outgoing) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
@@ -123,6 +124,7 @@ public final class SignedMessagesV2DAO {
|
||||
markMessageReadByReceipt(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -136,7 +138,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||
public ApplyStatus upsertIncomingCopy(SignedMessageEntry incoming) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
@@ -161,6 +163,7 @@ public final class SignedMessagesV2DAO {
|
||||
upsertMessage(c, incoming);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
@@ -173,7 +176,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||
public ApplyStatus applyDeleteMessage(SignedMessageEntry tombstone) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
@@ -191,6 +194,7 @@ public final class SignedMessagesV2DAO {
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -204,7 +208,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||
public ApplyStatus applyDeleteConversation(SignedMessageEntry tombstone) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
@@ -219,6 +223,7 @@ public final class SignedMessagesV2DAO {
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -232,7 +237,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||
public SignedMessageEntry getByMessageKey(String messageKey) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
@@ -240,9 +245,9 @@ public final class SignedMessagesV2DAO {
|
||||
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 signed_messages_v2
|
||||
FROM %s
|
||||
WHERE message_key = ?
|
||||
""";
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
@@ -253,7 +258,7 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
||||
public SignedMessageEntry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getLatestConversationDelete(c, fromLogin, toLogin);
|
||||
}
|
||||
@@ -268,9 +273,10 @@ public final class SignedMessagesV2DAO {
|
||||
withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
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) {
|
||||
@@ -314,22 +320,23 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||
public List<SignedMessageEntry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String fillSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
INSERT 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
|
||||
FROM %s 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)
|
||||
(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 (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||
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);
|
||||
@@ -347,13 +354,13 @@ public final class SignedMessagesV2DAO {
|
||||
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 signed_messages_v2 m
|
||||
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
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
""".formatted(messagesTable());
|
||||
List<SignedMessageEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
@@ -365,7 +372,7 @@ public final class SignedMessagesV2DAO {
|
||||
});
|
||||
}
|
||||
|
||||
public List<SignedMessageV2Entry> listConversationPage(
|
||||
public List<SignedMessageEntry> listConversationPage(
|
||||
String login,
|
||||
String peerLogin,
|
||||
long beforeTimeMs,
|
||||
@@ -379,12 +386,12 @@ public final class SignedMessagesV2DAO {
|
||||
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 signed_messages_v2
|
||||
WHERE target_login = ? COLLATE NOCASE
|
||||
FROM %s
|
||||
WHERE LOWER(target_login) = LOWER(?)
|
||||
AND message_type IN (1, 2)
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND (
|
||||
? <= 0
|
||||
@@ -393,8 +400,8 @@ public final class SignedMessagesV2DAO {
|
||||
)
|
||||
ORDER BY time_ms DESC, message_key DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
""".formatted(messagesTable());
|
||||
List<SignedMessageEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setString(2, login);
|
||||
@@ -415,9 +422,75 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
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 signed_messages_v2 (
|
||||
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,
|
||||
@@ -439,15 +512,15 @@ public final class SignedMessagesV2DAO {
|
||||
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(signed_messages_v2.read_at_ms, excluded.read_at_ms)
|
||||
""";
|
||||
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, SignedMessageV2Entry entry) throws SQLException {
|
||||
private void markMessageReadByReceipt(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||
if (entry == null) return;
|
||||
int messageType = entry.getMessageType();
|
||||
if (messageType != 3 && messageType != 4) return;
|
||||
@@ -456,7 +529,7 @@ public final class SignedMessagesV2DAO {
|
||||
long readAtMs = entry.getTimeMs();
|
||||
if (readAtMs <= 0) return;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_messages_v2
|
||||
UPDATE %s
|
||||
SET read_at_ms = CASE
|
||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||
WHEN read_at_ms > ? THEN ?
|
||||
@@ -464,7 +537,7 @@ public final class SignedMessagesV2DAO {
|
||||
END
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (1, 2)
|
||||
""")) {
|
||||
""".formatted(messagesTable()))) {
|
||||
ps.setLong(1, readAtMs);
|
||||
ps.setLong(2, readAtMs);
|
||||
ps.setLong(3, readAtMs);
|
||||
@@ -476,10 +549,10 @@ public final class SignedMessagesV2DAO {
|
||||
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
FROM signed_messages_v2
|
||||
FROM %s
|
||||
WHERE message_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
""".formatted(messagesTable());
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
@@ -492,12 +565,12 @@ public final class SignedMessagesV2DAO {
|
||||
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
FROM signed_messages_v2
|
||||
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()) {
|
||||
@@ -510,11 +583,11 @@ public final class SignedMessagesV2DAO {
|
||||
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM signed_messages_v2
|
||||
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()) {
|
||||
@@ -531,13 +604,13 @@ public final class SignedMessagesV2DAO {
|
||||
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM signed_messages_v2
|
||||
FROM %s
|
||||
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)
|
||||
(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);
|
||||
@@ -551,22 +624,22 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
|
||||
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
|
||||
FROM signed_messages_v2
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM %s
|
||||
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)
|
||||
(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);
|
||||
@@ -582,16 +655,16 @@ public final class SignedMessagesV2DAO {
|
||||
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
FROM %s
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""", baseKey, baseKey);
|
||||
""".formatted(messagesTable()), baseKey, baseKey);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM signed_messages_v2
|
||||
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();
|
||||
@@ -601,22 +674,22 @@ public final class SignedMessagesV2DAO {
|
||||
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
FROM %s
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
""", boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||
""".formatted(messagesTable()), boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM signed_messages_v2
|
||||
DELETE FROM %s
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
(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);
|
||||
@@ -645,22 +718,22 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
private int insertStrict(Connection c, SignedMessageEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
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, SignedMessageV2Entry e) throws SQLException {
|
||||
private void bindSignedMessage(PreparedStatement ps, SignedMessageEntry e) throws SQLException {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
@@ -703,43 +776,8 @@ public final class SignedMessagesV2DAO {
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
}
|
||||
|
||||
private boolean isBusyLock(SQLException ex) {
|
||||
Throwable current = ex;
|
||||
while (current != null) {
|
||||
String msg = String.valueOf(current.getMessage()).toLowerCase();
|
||||
if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) {
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sleepBeforeBusyRetry(int attempt) throws SQLException {
|
||||
long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4));
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie);
|
||||
throw sqlEx;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
|
||||
SQLException lastBusy = null;
|
||||
for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return work.run();
|
||||
} catch (SQLException ex) {
|
||||
if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) {
|
||||
throw ex;
|
||||
}
|
||||
lastBusy = ex;
|
||||
sleepBeforeBusyRetry(attempt);
|
||||
}
|
||||
}
|
||||
throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy;
|
||||
return work.run();
|
||||
}
|
||||
|
||||
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||
@@ -748,8 +786,12 @@ public final class SignedMessagesV2DAO {
|
||||
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
|
||||
}
|
||||
|
||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||
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"));
|
||||
@@ -773,11 +815,13 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
|
||||
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
|
||||
private static RevisionMarker of(SignedMessageV2Entry entry) {
|
||||
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;
|
||||
@@ -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() {}
|
||||
|
||||
|
||||
+3
-3
@@ -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)) {
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает первый access server пользователя ровно в порядке
|
||||
* solana_user_pda_current.access_servers_json (access_servers[0]).
|
||||
*
|
||||
* В user_access_servers_current порядок массива намеренно не хранится,
|
||||
* поэтому для auth-routing первичный сервер читается напрямую из PDA snapshot.
|
||||
*/
|
||||
public UserAccessServerRouteEntry findPrimaryByUserLogin(String userLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
u.login AS user_login,
|
||||
s.login AS server_login,
|
||||
s.server_address AS server_url,
|
||||
s.client_key AS server_client_key
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT BTRIM(a.login_value) AS server_login
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN BTRIM(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS a(login_value, ord)
|
||||
WHERE a.ord = 1
|
||||
) primary_access
|
||||
JOIN solana_user_pda_current s
|
||||
ON s.normalized_login = LOWER(primary_access.server_login)
|
||||
AND s.is_server = TRUE
|
||||
AND BTRIM(COALESCE(s.server_address, '')) <> ''
|
||||
WHERE u.normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, userLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает уникальные физические серверы из текущей routing-проекции.
|
||||
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
|
||||
* а не на каждого пользователя этого сервера.
|
||||
*/
|
||||
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (LOWER(server_login))
|
||||
server_login, server_url
|
||||
FROM user_access_servers_current
|
||||
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
|
||||
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
|
||||
ORDER BY LOWER(server_login), server_login, server_url
|
||||
""";
|
||||
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||
entry.setUserLogin("");
|
||||
entry.setServerLogin(rs.getString("server_login"));
|
||||
entry.setServerUrl(rs.getString("server_url"));
|
||||
result.add(entry);
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public final class UserNotificationSeenStateDAO {
|
||||
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||
private UserNotificationSeenStateDAO() {}
|
||||
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||
|
||||
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||
ps.setString(1, login); ps.setString(2, category);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||
}
|
||||
}
|
||||
|
||||
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||
String sql = """
|
||||
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return getSeenAt(c, login, category);
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
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;
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) 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 = ?
|
||||
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||
ps.setLong(4, Math.max(0, cutoffMs));
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||
}
|
||||
}
|
||||
|
||||
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() { }
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class UserParamsDAO {
|
||||
WHERE users_params.time_ms < excluded.time_ms
|
||||
""";
|
||||
|
||||
int changed;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setString(2, e.getParam());
|
||||
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
|
||||
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
||||
else ps.setNull(6, Types.VARCHAR);
|
||||
|
||||
return ps.executeUpdate();
|
||||
changed = ps.executeUpdate();
|
||||
}
|
||||
if (changed > 0 && affectsFastProfile(e.getParam())) {
|
||||
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserParamEntry e) throws SQLException {
|
||||
@@ -77,6 +82,32 @@ public final class UserParamsDAO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static boolean affectsFastProfile(String param) {
|
||||
if (param == null) return false;
|
||||
return switch (param) {
|
||||
case "first_name", "last_name", "ava", "account_role", "shine" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static void refreshFastProfileAndDerivedState(Connection c, String login, String param) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
if ("account_role".equals(param) || "shine".equals(param)) {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_message_qualified_likes_for_actor(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
||||
@@ -89,7 +120,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 +151,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,142 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
|
||||
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
||||
public final class UserProfileStateDAO {
|
||||
private static volatile UserProfileStateDAO instance;
|
||||
private UserProfileStateDAO() {}
|
||||
public static UserProfileStateDAO getInstance() {
|
||||
if (instance == null) synchronized (UserProfileStateDAO.class) {
|
||||
if (instance == null) instance = new UserProfileStateDAO();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ProfileCard get(Connection c, String login) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, first_name, last_name, ava_ar, account_role, shine_status
|
||||
FROM user_profile_state WHERE LOWER(login)=LOWER(?) LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return new ProfileCard(login, "", "", "", null, null);
|
||||
return map(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Effective outgoing social relation with priority close_friend > friend > contact > none.
|
||||
*
|
||||
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
|
||||
* older/current blocks may address a user by blockchain name, so a direct to_login-only
|
||||
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
|
||||
*/
|
||||
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
|
||||
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
|
||||
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||
return "friend";
|
||||
}
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
||||
int safeOffset = Math.max(0, offset);
|
||||
String where = switch (String.valueOf(listType)) {
|
||||
case "friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=14 AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(cs.login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)";
|
||||
case "close_friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=10";
|
||||
case "primary_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||
case "primary_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||
case "shine_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||
case "shine_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||
default -> throw new IllegalArgumentException("Unsupported listType: " + listType);
|
||||
};
|
||||
boolean received = "primary_received".equals(listType) || "shine_received".equals(listType);
|
||||
String personExpr = received ? "cs.login" : "cs.to_login";
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (LOWER(%s))
|
||||
%s AS person_login,
|
||||
COALESCE(up.first_name,'') AS first_name,
|
||||
COALESCE(up.last_name,'') AS last_name,
|
||||
COALESCE(up.ava_ar,'') AS ava_ar,
|
||||
up.account_role, up.shine_status,
|
||||
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=80
|
||||
AND shine_is_primary(x.login) AND shine_target_accepts_primary_confirmation(x.to_login)) AS primary_confirmed,
|
||||
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=70
|
||||
AND shine_is_primary(x.login) AND shine_is_shining(x.login) AND shine_target_accepts_shine_confirmation(x.to_login)) AS shine_confirmed,
|
||||
CASE
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=10) THEN 'close_friend'
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=14) THEN 'friend'
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=20) THEN 'contact'
|
||||
ELSE 'none' END AS relation_type,
|
||||
COALESCE(us.primary_confirmations_received_count,0) AS primary_confirmations_count,
|
||||
COALESCE(us.shine_confirmations_received_count,0) AS shine_confirmations_count
|
||||
FROM connections_state cs
|
||||
LEFT JOIN user_profile_state up ON LOWER(up.login)=LOWER(%s)
|
||||
LEFT JOIN user_stats_state us ON LOWER(us.login)=LOWER(%s)
|
||||
WHERE %s
|
||||
ORDER BY LOWER(%s), %s
|
||||
LIMIT ? OFFSET ?
|
||||
""".formatted(personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, where, personExpr, personExpr);
|
||||
List<RelationCard> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i=1;
|
||||
ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin);
|
||||
ps.setString(i++, ownerLogin);
|
||||
ps.setInt(i++, safeLimit); ps.setInt(i, safeOffset);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(new RelationCard(
|
||||
rs.getString("person_login"), rs.getString("first_name"), rs.getString("last_name"), rs.getString("ava_ar"),
|
||||
rs.getString("account_role"), rs.getString("shine_status"), rs.getBoolean("primary_confirmed"), rs.getBoolean("shine_confirmed"),
|
||||
rs.getString("relation_type"), rs.getInt("primary_confirmations_count"), rs.getInt("shine_confirmations_count")
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<ChannelCard> listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException {
|
||||
int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset);
|
||||
String sql;
|
||||
if ("owned".equals(mode)) {
|
||||
sql="""
|
||||
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
||||
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||
""";
|
||||
} else if ("following".equals(mode)) {
|
||||
sql="""
|
||||
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
||||
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||
""";
|
||||
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
||||
List<ChannelCard> out=new ArrayList<>();
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,login);ps.setInt(2,safeLimit);ps.setInt(3,safeOffset);
|
||||
try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new ChannelCard(rs.getString("owner_login"),rs.getString("slug"),rs.getString("display_name"),rs.getString("ava_ar"),rs.getString("owner_bch_name"),rs.getInt("channel_root_block_number"),rs.getString("root_hash")));}}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static ProfileCard map(ResultSet rs)throws SQLException{return new ProfileCard(rs.getString("login"),rs.getString("first_name"),rs.getString("last_name"),rs.getString("ava_ar"),rs.getString("account_role"),rs.getString("shine_status"));}
|
||||
public record ProfileCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus){}
|
||||
public record RelationCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus,boolean primaryConfirmed,boolean shineConfirmed,String relationType,int primaryConfirmationsCount,int shineConfirmationsCount){}
|
||||
public record ChannelCard(String ownerLogin,String slug,String displayName,String avatarAr,String ownerBlockchainName,int rootBlockNumber,String rootBlockHashHex){}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Локальное хранение подписанных пользовательских настроек.
|
||||
* Запись уникальна по login + setting_type + setting_key и обновляется только
|
||||
* более новым time_ms. Межсерверной репликации настроек нет.
|
||||
*/
|
||||
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
|
||||
) 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
|
||||
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());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+152
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -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; }
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmDeliveryStateEntry {
|
||||
public static final int ACCEPTED = 0;
|
||||
public static final int DELIVERED = 1;
|
||||
public static final int FAILED = 3;
|
||||
|
||||
// Совместимость со строками БД, созданными ранней экспериментальной миграцией.
|
||||
public static final int PENDING_NONE = ACCEPTED;
|
||||
public static final int DELIVERED_ONE = DELIVERED;
|
||||
public static final int DELIVERED_ALL = 2;
|
||||
public static final int FAILED_FINAL = FAILED;
|
||||
|
||||
private String outgoingMessageKey;
|
||||
private String eventId;
|
||||
private String baseKey;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String incomingMessageKey;
|
||||
private long createdAtMs;
|
||||
private long deliveryExpiresAtMs;
|
||||
private int deliveryState;
|
||||
private String deliveredServerLogin;
|
||||
private String recipientRoutesHash;
|
||||
private int attemptIndex;
|
||||
private Long nextAttemptAtMs;
|
||||
private Long lastAttemptAtMs;
|
||||
private String lastError;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOutgoingMessageKey() { return outgoingMessageKey; }
|
||||
public void setOutgoingMessageKey(String value) { this.outgoingMessageKey = value; }
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String value) { this.eventId = value; }
|
||||
public String getBaseKey() { return baseKey; }
|
||||
public void setBaseKey(String value) { this.baseKey = value; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String value) { this.fromLogin = value; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String value) { this.toLogin = value; }
|
||||
public String getIncomingMessageKey() { return incomingMessageKey; }
|
||||
public void setIncomingMessageKey(String value) { this.incomingMessageKey = value; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
|
||||
public long getDeliveryExpiresAtMs() { return deliveryExpiresAtMs; }
|
||||
public void setDeliveryExpiresAtMs(long value) { this.deliveryExpiresAtMs = value; }
|
||||
public int getDeliveryState() { return deliveryState; }
|
||||
public void setDeliveryState(int value) { this.deliveryState = value; }
|
||||
public String getDeliveredServerLogin() { return deliveredServerLogin; }
|
||||
public void setDeliveredServerLogin(String value) { this.deliveredServerLogin = value; }
|
||||
public String getRecipientRoutesHash() { return recipientRoutesHash; }
|
||||
public void setRecipientRoutesHash(String value) { this.recipientRoutesHash = value; }
|
||||
public int getAttemptIndex() { return attemptIndex; }
|
||||
public void setAttemptIndex(int value) { this.attemptIndex = value; }
|
||||
public Long getNextAttemptAtMs() { return nextAttemptAtMs; }
|
||||
public void setNextAttemptAtMs(Long value) { this.nextAttemptAtMs = value; }
|
||||
public Long getLastAttemptAtMs() { return lastAttemptAtMs; }
|
||||
public void setLastAttemptAtMs(Long value) { this.lastAttemptAtMs = value; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String value) { this.lastError = value; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
|
||||
|
||||
public String deliveryStateCode() {
|
||||
return switch (deliveryState) {
|
||||
case DELIVERED_ONE, DELIVERED_ALL -> "delivered";
|
||||
case FAILED_FINAL -> "failed";
|
||||
default -> "accepted";
|
||||
};
|
||||
}
|
||||
|
||||
public static int parseStateCode(String value) {
|
||||
if (value == null) return ACCEPTED;
|
||||
return switch (value.trim().toLowerCase()) {
|
||||
case "delivered", "delivered_one", "delivered_all" -> DELIVERED;
|
||||
case "failed", "failed_final" -> FAILED;
|
||||
default -> ACCEPTED;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean isDelivered() {
|
||||
return deliveryState == DELIVERED_ONE || deliveryState == DELIVERED_ALL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmSyncOutboxEntry {
|
||||
private String ownerLogin;
|
||||
private String primaryMessageKey;
|
||||
private String eventId;
|
||||
private String secondaryMessageKey;
|
||||
private boolean synced;
|
||||
private long createdAtMs;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String value) { this.ownerLogin = value; }
|
||||
public String getPrimaryMessageKey() { return primaryMessageKey; }
|
||||
public void setPrimaryMessageKey(String value) { this.primaryMessageKey = value; }
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String value) { this.eventId = value; }
|
||||
public String getSecondaryMessageKey() { return secondaryMessageKey; }
|
||||
public void setSecondaryMessageKey(String value) { this.secondaryMessageKey = value; }
|
||||
public boolean isSynced() { return synced; }
|
||||
public void setSynced(boolean value) { this.synced = value; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
|
||||
}
|
||||
+32
@@ -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; }
|
||||
}
|
||||
-35
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedMessageV2Entry {
|
||||
public class SignedMessageEntry {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
private String targetLogin;
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Локальная routing-проекция access server пользователя.
|
||||
*
|
||||
* Источник:
|
||||
* - user_access_servers_current
|
||||
*/
|
||||
public class UserAccessServerRouteEntry {
|
||||
|
||||
private String userLogin;
|
||||
private String serverLogin;
|
||||
private String serverUrl;
|
||||
private String serverClientKey;
|
||||
|
||||
public String getUserLogin() { return userLogin; }
|
||||
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
|
||||
|
||||
public String getServerLogin() { return serverLogin; }
|
||||
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||
|
||||
public String getServerUrl() { return serverUrl; }
|
||||
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
|
||||
|
||||
public String getServerClientKey() { return serverClientKey; }
|
||||
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserNotificationEntry {
|
||||
private String ownerLogin;
|
||||
private String notificationKind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBchName;
|
||||
private int sourceBlockNumber;
|
||||
private byte[] sourceBlockHash;
|
||||
private String targetLogin;
|
||||
private String targetBchName;
|
||||
private Integer targetBlockNumber;
|
||||
private byte[] targetBlockHash;
|
||||
private int sourceMsgSubType;
|
||||
private String sourceText;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getNotificationKind() { return notificationKind; }
|
||||
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBchName() { return sourceBchName; }
|
||||
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBchName() { return targetBchName; }
|
||||
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/** Одна локально сохранённая подписанная пользовательская настройка. */
|
||||
public class UserSettingEntry {
|
||||
private String login;
|
||||
private int settingType;
|
||||
private String settingKey;
|
||||
private long timeMs;
|
||||
private String valueText;
|
||||
private long valueNum;
|
||||
private String clientKey;
|
||||
private String signature;
|
||||
|
||||
public UserSettingEntry() {}
|
||||
|
||||
public UserSettingEntry(
|
||||
String login,
|
||||
int settingType,
|
||||
String settingKey,
|
||||
long timeMs,
|
||||
String valueText,
|
||||
long valueNum,
|
||||
String clientKey,
|
||||
String signature
|
||||
) {
|
||||
this.login = login;
|
||||
this.settingType = settingType;
|
||||
this.settingKey = settingKey;
|
||||
this.timeMs = timeMs;
|
||||
this.valueText = valueText;
|
||||
this.valueNum = valueNum;
|
||||
this.clientKey = clientKey;
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public int getSettingType() { return settingType; }
|
||||
public void setSettingType(int settingType) { this.settingType = settingType; }
|
||||
public String getSettingKey() { return settingKey; }
|
||||
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public String getValueText() { return valueText; }
|
||||
public void setValueText(String valueText) { this.valueText = valueText; }
|
||||
public long getValueNum() { return valueNum; }
|
||||
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
|
||||
public String getClientKey() { return clientKey; }
|
||||
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserSettingsSyncPeerStateEntry {
|
||||
private String ownerLogin;
|
||||
private String remoteServerLogin;
|
||||
private String remoteServerUrl;
|
||||
private long cursorTimeMs;
|
||||
private String cursorSettingKey;
|
||||
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 getCursorTimeMs() { return cursorTimeMs; }
|
||||
public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; }
|
||||
|
||||
public String getCursorSettingKey() { return cursorSettingKey; }
|
||||
public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; }
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package shine.db.sql;
|
||||
|
||||
public final class CurrentUsersSql {
|
||||
|
||||
private CurrentUsersSql() {}
|
||||
|
||||
public static String usersSubquery(String alias) {
|
||||
if (alias == null || alias.isBlank()) {
|
||||
throw new IllegalArgumentException("alias is blank");
|
||||
}
|
||||
return """
|
||||
(
|
||||
SELECT
|
||||
current_users.login AS login,
|
||||
current_users.normalized_login AS normalized_login,
|
||||
current_users.blockchain_name AS blockchain_name,
|
||||
current_users.client_key AS solana_key,
|
||||
current_users.blockchain_key AS blockchain_key,
|
||||
current_users.client_key AS client_key,
|
||||
current_users.is_server AS is_server
|
||||
FROM solana_user_pda_current current_users
|
||||
) %s
|
||||
""".formatted(alias);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
|
||||
source_block_hash BYTEA NOT NULL,
|
||||
target_login TEXT,
|
||||
target_bch_name TEXT,
|
||||
target_block_number INTEGER,
|
||||
target_block_hash BYTEA,
|
||||
source_msg_sub_type INTEGER NOT NULL,
|
||||
source_text TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
|
||||
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
|
||||
ON user_notifications_state(owner_login, created_at_ms DESC);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,30 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_dialog_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
peer_login TEXT NOT NULL,
|
||||
relation_flag TEXT NOT NULL DEFAULT 'none',
|
||||
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
|
||||
last_message_key TEXT NOT NULL DEFAULT '',
|
||||
last_message_type INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, peer_login)
|
||||
);
|
||||
|
||||
ALTER TABLE IF EXISTS dm_dialog_state
|
||||
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
|
||||
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 11, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,139 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_delivery_state (
|
||||
outgoing_message_key TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
base_key TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
incoming_message_key TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
delivery_expires_at_ms BIGINT NOT NULL,
|
||||
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
|
||||
delivered_server_login TEXT,
|
||||
recipient_routes_hash TEXT,
|
||||
attempt_index INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at_ms BIGINT,
|
||||
last_attempt_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
|
||||
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
|
||||
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
|
||||
ON dm_delivery_state(base_key, from_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
|
||||
owner_login TEXT NOT NULL,
|
||||
primary_message_key TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
secondary_message_key TEXT,
|
||||
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, primary_message_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
|
||||
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
|
||||
WHERE synced = FALSE;
|
||||
|
||||
-- До v12 факт межсерверной доставки не сохранялся. Старые исходящие пары
|
||||
-- считаем уже доставленными, чтобы обновление не вызвало повторную рассылку.
|
||||
INSERT INTO dm_delivery_state (
|
||||
outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
outgoing.message_key,
|
||||
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
|
||||
outgoing.base_key,
|
||||
outgoing.from_login,
|
||||
outgoing.to_login,
|
||||
incoming.message_key,
|
||||
outgoing.created_at_ms,
|
||||
outgoing.created_at_ms + 3600000,
|
||||
2,
|
||||
NULL,
|
||||
NULL,
|
||||
9,
|
||||
NULL,
|
||||
NULL,
|
||||
'MIGRATED_AS_DELIVERED',
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages outgoing
|
||||
JOIN signed_messages incoming
|
||||
ON incoming.base_key = outgoing.base_key
|
||||
AND incoming.message_type = outgoing.message_type - 1
|
||||
WHERE outgoing.message_type IN (2, 4)
|
||||
ON CONFLICT (outgoing_message_key) DO NOTHING;
|
||||
|
||||
-- Историю до v12 считаем подтверждённой, иначе само обновление вызовет
|
||||
-- массовую повторную передачу. При замене сервера общий reset вернёт FALSE.
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
LOWER(outgoing.from_login),
|
||||
outgoing.message_key,
|
||||
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
|
||||
incoming.message_key,
|
||||
TRUE,
|
||||
outgoing.created_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages outgoing
|
||||
JOIN signed_messages incoming
|
||||
ON incoming.base_key = outgoing.base_key
|
||||
AND incoming.message_type = outgoing.message_type - 1
|
||||
WHERE outgoing.message_type IN (2, 4)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||
|
||||
-- Входящая копия синхронизируется отдельно между серверами получателя.
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
LOWER(incoming.to_login),
|
||||
incoming.message_key,
|
||||
incoming.message_key || ':' || incoming.revision_time_ms || ':' || incoming.reencrypted_at_ms,
|
||||
NULL,
|
||||
TRUE,
|
||||
incoming.created_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages incoming
|
||||
WHERE incoming.message_type IN (1, 3)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||
|
||||
-- Tombstone принадлежит обоим участникам и должен попасть на второй сервер
|
||||
-- каждого из них. Для self-DM конфликт безопасно схлопывается.
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
LOWER(owner_login),
|
||||
tombstone.message_key,
|
||||
tombstone.message_key || ':' || tombstone.revision_time_ms || ':' || tombstone.reencrypted_at_ms,
|
||||
NULL,
|
||||
TRUE,
|
||||
tombstone.created_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages tombstone
|
||||
CROSS JOIN LATERAL (VALUES (tombstone.from_login), (tombstone.to_login)) owners(owner_login)
|
||||
WHERE tombstone.message_type IN (5, 6, 7, 8)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 12, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,46 @@
|
||||
BEGIN;
|
||||
|
||||
-- Нормализация экспериментальной схемы доставки v12:
|
||||
-- 0=accepted, 1=delivered, 3=failed. Старое delivered_all (2)
|
||||
-- объединяется с delivered, поскольку ACK одного сервера теперь достаточен.
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = 1,
|
||||
delivered_server_login = NULL,
|
||||
next_attempt_at_ms = NULL,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE delivery_state = 2;
|
||||
|
||||
-- Для незавершённых записей перестраиваем только будущую очередь. Повторная
|
||||
-- передача безопасна благодаря messageKey и идемпотентному приёму.
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_expires_at_ms = created_at_ms + 3600000,
|
||||
attempt_index = CASE
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000 THEN 4
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000 THEN 3
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000 THEN 2
|
||||
ELSE 1
|
||||
END,
|
||||
next_attempt_at_ms = CASE
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000
|
||||
THEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000
|
||||
THEN created_at_ms + 3600000
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000
|
||||
THEN created_at_ms + 1500000
|
||||
ELSE created_at_ms + 30000
|
||||
END,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE delivery_state = 0;
|
||||
|
||||
DROP INDEX IF EXISTS idx_dm_delivery_state_due;
|
||||
CREATE INDEX idx_dm_delivery_state_due
|
||||
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
|
||||
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 13, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,620 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_stats_state (
|
||||
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
|
||||
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
|
||||
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
|
||||
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
|
||||
ON user_stats_state (following_users_count);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_stats_state (
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
|
||||
channel_root_block_hash BYTEA NOT NULL,
|
||||
owner_login TEXT NOT NULL,
|
||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
|
||||
ON channel_stats_state (owner_login);
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
now_ms BIGINT;
|
||||
BEGIN
|
||||
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (login) DO NOTHING;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
now_ms BIGINT;
|
||||
public_subscribers_count INTEGER;
|
||||
BEGIN
|
||||
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
|
||||
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_login,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
SELECT COUNT(*)::INTEGER
|
||||
INTO public_subscribers_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.owner_bch_name = NEW.owner_bch_name
|
||||
AND cn.channel_root_block_number = NEW.channel_root_block_number
|
||||
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
|
||||
AND cn.channel_type_code = 1;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_bch_name,
|
||||
NEW.channel_root_block_number,
|
||||
NEW.channel_root_block_hash,
|
||||
NEW.owner_login,
|
||||
NEW.channel_type_code,
|
||||
COALESCE(public_subscribers_count, 0),
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = EXCLUDED.subscribers_count,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
WITH pending AS (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type = 30
|
||||
AND cs.to_bch_name = NEW.owner_bch_name
|
||||
AND cs.to_block_number = NEW.channel_root_block_number
|
||||
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||
GROUP BY cs.login
|
||||
)
|
||||
UPDATE user_stats_state us
|
||||
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||
updated_at_ms = now_ms
|
||||
FROM pending
|
||||
WHERE us.login = pending.login;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_type INTEGER;
|
||||
existed_before BOOLEAN;
|
||||
target_channel_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type <> 3 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
|
||||
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
)
|
||||
INTO existed_before;
|
||||
|
||||
IF NOT existed_before THEN
|
||||
IF NEW.msg_sub_type = 10 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
close_friends_count = user_stats_state.close_friends_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.msg_sub_type = 30 THEN
|
||||
IF NEW.to_block_number IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.to_block_number = 0 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_users_count = user_stats_state.following_users_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
SELECT cn.channel_type_code
|
||||
INTO target_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||
AND cn.channel_root_block_number = NEW.to_block_number
|
||||
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF target_channel_type = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash,
|
||||
resolved_login,
|
||||
target_channel_type,
|
||||
1,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = channel_stats_state.subscribers_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF target_channel_type IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||
|
||||
INSERT INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
NEW.msg_sub_type,
|
||||
resolved_login,
|
||||
NEW.to_bch_name,
|
||||
COALESCE(NEW.to_block_number, 0),
|
||||
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
positive_rel_type := CASE NEW.msg_sub_type
|
||||
WHEN 11 THEN 10
|
||||
WHEN 21 THEN 20
|
||||
WHEN 31 THEN 30
|
||||
WHEN 41 THEN 40
|
||||
WHEN 51 THEN 50
|
||||
WHEN 53 THEN 52
|
||||
WHEN 55 THEN 54
|
||||
WHEN 61 THEN 60
|
||||
WHEN 71 THEN 70
|
||||
WHEN 75 THEN 74
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = positive_rel_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
)
|
||||
INTO existed_before;
|
||||
|
||||
IF NOT existed_before THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF positive_rel_type = 10 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF positive_rel_type = 30 THEN
|
||||
IF NEW.to_block_number IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.to_block_number = 0 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
SELECT cn.channel_type_code
|
||||
INTO target_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||
AND cn.channel_root_block_number = NEW.to_block_number
|
||||
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF target_channel_type = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash,
|
||||
resolved_login,
|
||||
target_channel_type,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF target_channel_type IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = positive_rel_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||
AFTER INSERT ON channel_names_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
|
||||
CREATE TRIGGER trg_blocks_connection_state_ai
|
||||
AFTER INSERT ON blocks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_blocks_connection_state_ai();
|
||||
|
||||
TRUNCATE TABLE user_stats_state, channel_stats_state;
|
||||
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
u.login,
|
||||
COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count,
|
||||
COALESCE(fu.following_users_count, 0) AS following_users_count,
|
||||
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
|
||||
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
|
||||
FROM solana_user_pda_current u
|
||||
LEFT JOIN (
|
||||
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
|
||||
FROM channel_names_state
|
||||
WHERE channel_type_code = 1
|
||||
GROUP BY owner_login
|
||||
) own ON LOWER(own.owner_login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT login, COUNT(*)::INTEGER AS following_users_count
|
||||
FROM connections_state
|
||||
WHERE rel_type = 30
|
||||
AND to_block_number = 0
|
||||
GROUP BY login
|
||||
) fu ON LOWER(fu.login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.channel_type_code = 1
|
||||
GROUP BY cs.login
|
||||
) fc ON LOWER(fc.login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT login, COUNT(*)::INTEGER AS close_friends_count
|
||||
FROM connections_state
|
||||
WHERE rel_type = 10
|
||||
GROUP BY login
|
||||
) cf ON LOWER(cf.login) = LOWER(u.login);
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
cn.owner_bch_name,
|
||||
cn.channel_root_block_number,
|
||||
cn.channel_root_block_hash,
|
||||
cn.owner_login,
|
||||
cn.channel_type_code,
|
||||
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
|
||||
FROM channel_names_state cn
|
||||
LEFT JOIN connections_state cs
|
||||
ON cs.rel_type = 30
|
||||
AND cs.to_bch_name = cn.owner_bch_name
|
||||
AND cs.to_block_number = cn.channel_root_block_number
|
||||
AND cs.to_block_hash = cn.channel_root_block_hash
|
||||
WHERE cn.channel_type_code = 1
|
||||
GROUP BY
|
||||
cn.owner_bch_name,
|
||||
cn.channel_root_block_number,
|
||||
cn.channel_root_block_hash,
|
||||
cn.owner_login,
|
||||
cn.channel_type_code;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 14, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,105 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
now_ms BIGINT;
|
||||
public_subscribers_count INTEGER;
|
||||
BEGIN
|
||||
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
|
||||
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM solana_user_pda_current su
|
||||
WHERE su.login = NEW.owner_login
|
||||
) THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_login,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*)::INTEGER
|
||||
INTO public_subscribers_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.owner_bch_name = NEW.owner_bch_name
|
||||
AND cn.channel_root_block_number = NEW.channel_root_block_number
|
||||
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
|
||||
AND cn.channel_type_code = 1;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_bch_name,
|
||||
NEW.channel_root_block_number,
|
||||
NEW.channel_root_block_hash,
|
||||
NEW.owner_login,
|
||||
NEW.channel_type_code,
|
||||
COALESCE(public_subscribers_count, 0),
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = EXCLUDED.subscribers_count,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
WITH pending AS (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type = 30
|
||||
AND cs.to_bch_name = NEW.owner_bch_name
|
||||
AND cs.to_block_number = NEW.channel_root_block_number
|
||||
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||
GROUP BY cs.login
|
||||
)
|
||||
UPDATE user_stats_state us
|
||||
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||
updated_at_ms = now_ms
|
||||
FROM pending
|
||||
WHERE us.login = pending.login;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 15, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,112 @@
|
||||
BEGIN;
|
||||
|
||||
-- У пользователя действует только первый access_server из Solana PDA.
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
DELETE FROM user_access_servers_current
|
||||
WHERE LOWER(user_login) = LOWER(p_user_login);
|
||||
|
||||
INSERT INTO user_access_servers_current (
|
||||
user_login,
|
||||
server_login,
|
||||
server_url,
|
||||
server_client_key,
|
||||
user_record_number,
|
||||
user_updated_at_ms,
|
||||
server_record_number,
|
||||
server_updated_at_ms,
|
||||
refreshed_at_ms
|
||||
)
|
||||
SELECT
|
||||
u.login,
|
||||
s.login,
|
||||
s.server_address,
|
||||
s.client_key,
|
||||
u.record_number,
|
||||
u.updated_at_ms,
|
||||
s.record_number,
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord = 1
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
AND btrim(COALESCE(s.server_address, '')) <> ''
|
||||
WHERE LOWER(u.login) = LOWER(p_user_login)
|
||||
ON CONFLICT (user_login, server_login) DO UPDATE SET
|
||||
server_url = EXCLUDED.server_url,
|
||||
server_client_key = EXCLUDED.server_client_key,
|
||||
user_record_number = EXCLUDED.user_record_number,
|
||||
user_updated_at_ms = EXCLUDED.user_updated_at_ms,
|
||||
server_record_number = EXCLUDED.server_record_number,
|
||||
server_updated_at_ms = EXCLUDED.server_updated_at_ms,
|
||||
refreshed_at_ms = EXCLUDED.refreshed_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
affected_user RECORD;
|
||||
BEGIN
|
||||
IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
DELETE FROM user_access_servers_current
|
||||
WHERE LOWER(server_login) = LOWER(p_server_login);
|
||||
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord = 1
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TABLE IF EXISTS dm_sync_outbox;
|
||||
DROP TABLE IF EXISTS dm_sync_peer_state;
|
||||
DROP TABLE IF EXISTS user_settings_sync_peer_state;
|
||||
DROP INDEX IF EXISTS idx_user_settings_synced;
|
||||
ALTER TABLE user_settings DROP COLUMN IF EXISTS synced;
|
||||
|
||||
SELECT shine_refresh_user_access_servers_all();
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 16, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user