SHA256
Compare commits
108
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 |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -98,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;
|
||||
/** Удалить связь "жена/муж". */
|
||||
@@ -137,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;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -147,11 +148,17 @@ 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 (обе строки). */
|
||||
|
||||
+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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+6
@@ -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";
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_6 = 6;
|
||||
public static final int SCHEMA_VERSION_7 = 7;
|
||||
public static final int SCHEMA_VERSION_8 = 8;
|
||||
public static final int SCHEMA_VERSION_9 = 9;
|
||||
public static final int SCHEMA_VERSION_10 = 10;
|
||||
public static final int SCHEMA_VERSION_11 = 11;
|
||||
public static final int SCHEMA_VERSION_12 = 12;
|
||||
public static final int SCHEMA_VERSION_13 = 13;
|
||||
public static final int SCHEMA_VERSION_14 = 14;
|
||||
public static final int SCHEMA_VERSION_15 = 15;
|
||||
public static final int SCHEMA_VERSION_16 = 16;
|
||||
public static final int SCHEMA_VERSION_17 = 17;
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
public static final int SCHEMA_VERSION_19 = 19;
|
||||
public static final int SCHEMA_VERSION_20 = 20;
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final int SCHEMA_VERSION_22 = 22;
|
||||
public static final int SCHEMA_VERSION_23 = 23;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -33,6 +48,21 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
|
||||
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
||||
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
|
||||
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
|
||||
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
|
||||
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
|
||||
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
|
||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
|
||||
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
|
||||
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
|
||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -51,17 +81,20 @@ public final class DatabaseInitializer {
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется; user-to-user FOLLOW legacy. DO NOT REMOVE.
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. DO NOT REMOVE.
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
public static final short CONNECTION_UNSPOUSE = 41;
|
||||
|
||||
@@ -74,15 +107,20 @@ public final class DatabaseInitializer {
|
||||
public static final short CONNECTION_SIBLING = 54;
|
||||
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;
|
||||
|
||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
||||
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;
|
||||
|
||||
public static void ensurePostgresSchemaInitialized(String jdbcUrl,
|
||||
String user,
|
||||
String password) throws SQLException {
|
||||
@@ -124,6 +162,67 @@ public final class DatabaseInitializer {
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_8) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_8;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_9) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_9;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_10) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_10;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_11) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_11;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_12) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V12_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_12;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_13) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_13;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_14) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_14;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_15) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_15;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_16) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_16;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_17) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_17;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_18) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_18;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_19) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_19;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_20) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_20;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_21) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_21;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_22) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_22;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_23) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_23;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
@@ -15,6 +16,7 @@ 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();
|
||||
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
|
||||
if (instance == null) {
|
||||
synchronized (DbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new DbController();
|
||||
DbController created = new DbController();
|
||||
instance = created;
|
||||
created.bootstrapDmDialogStateIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,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;
|
||||
@@ -82,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;
|
||||
|
||||
@@ -112,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;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -124,12 +124,19 @@ 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 (обе строки). */
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+121
-6
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,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.
|
||||
*/
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class DirectMessagesDAO {
|
||||
private static volatile DirectMessagesDAO instance;
|
||||
private final DbController db = DbController.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;
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDirectMessagesHistoryDAO {
|
||||
private static volatile SignedDirectMessagesHistoryDAO instance;
|
||||
private final DbController db = DbController.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,51 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDmReplayDAO {
|
||||
private static volatile SignedDmReplayDAO instance;
|
||||
private final DbController db = DbController.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 INTO signed_direct_message_replay (
|
||||
from_login, time_ms, nonce, created_at_ms
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""";
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ public final class SignedMessagesDAO {
|
||||
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
if (status.applied()) {
|
||||
markMessageReadByReceipt(c, e);
|
||||
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
markMessageReadByReceipt(c, first);
|
||||
markMessageReadByReceipt(c, second);
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
|
||||
markMessageReadByReceipt(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
|
||||
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) {
|
||||
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
|
||||
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;
|
||||
|
||||
+71
@@ -36,6 +36,48 @@ public final class UserAccessServersCurrentDAO {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает первый 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
|
||||
@@ -75,6 +117,35 @@ public final class UserAccessServersCurrentDAO {
|
||||
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"));
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
-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; }
|
||||
}
|
||||
+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,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;
|
||||
@@ -0,0 +1,381 @@
|
||||
BEGIN;
|
||||
|
||||
-- Add active FRIEND 14/15 and OFFICIAL_ACCOUNT confirmation 80/81 to connection state.
|
||||
-- Legacy 60/61 remain accepted at protocol level; this migration does not reinterpret existing rows.
|
||||
|
||||
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, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) 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 15 THEN 14
|
||||
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
|
||||
WHEN 81 THEN 80
|
||||
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;
|
||||
$$;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 17, 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,348 @@
|
||||
BEGIN;
|
||||
|
||||
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
|
||||
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_state (
|
||||
login TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
ava_ar TEXT NOT NULL DEFAULT '',
|
||||
account_role TEXT,
|
||||
shine_status TEXT,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
|
||||
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
|
||||
|
||||
ALTER TABLE user_stats_state
|
||||
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
|
||||
|
||||
ALTER TABLE message_stats
|
||||
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_first_name TEXT := '';
|
||||
v_last_name TEXT := '';
|
||||
v_ava_ar TEXT := '';
|
||||
v_account_role TEXT := NULL;
|
||||
v_shine TEXT := NULL;
|
||||
v_updated BIGINT := 0;
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
|
||||
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
|
||||
MAX(value) FILTER (WHERE param = 'account_role'),
|
||||
MAX(value) FILTER (WHERE param = 'shine'),
|
||||
COALESCE(MAX(time_ms), 0)
|
||||
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
|
||||
FROM users_params
|
||||
WHERE LOWER(login) = LOWER(p_login);
|
||||
|
||||
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
|
||||
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
|
||||
|
||||
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
|
||||
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
|
||||
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
|
||||
|
||||
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
|
||||
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
ava_ar = EXCLUDED.ava_ar,
|
||||
account_role = EXCLUDED.account_role,
|
||||
shine_status = EXCLUDED.shine_status,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
|
||||
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||
following_channels_count, close_friends_count, friends_count,
|
||||
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||
SELECT p_login,
|
||||
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||
0,
|
||||
(SELECT COUNT(*)::INT 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(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||
v_now
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||
following_users_count=0,
|
||||
following_channels_count=EXCLUDED.following_channels_count,
|
||||
close_friends_count=EXCLUDED.close_friends_count,
|
||||
friends_count=EXCLUDED.friends_count,
|
||||
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
PERFORM shine_refresh_user_stats(p_login);
|
||||
FOR r IN
|
||||
SELECT DISTINCT x.login FROM (
|
||||
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
UNION
|
||||
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
) x
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_stats(r.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
|
||||
FROM reactions_state rs
|
||||
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
LOOP
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_login TEXT;
|
||||
v_channel_type INTEGER;
|
||||
v_count INTEGER := 0;
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT cn.owner_login, cn.channel_type_code
|
||||
INTO v_owner_login, v_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name=p_bch_name
|
||||
AND cn.channel_root_block_number=p_block_number
|
||||
AND cn.channel_root_block_hash=p_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF v_channel_type IS NULL THEN RETURN; END IF;
|
||||
|
||||
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
|
||||
INTO v_count
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type=30
|
||||
AND cs.to_bch_name=p_bch_name
|
||||
AND cs.to_block_number=p_block_number
|
||||
AND cs.to_block_hash=p_block_hash;
|
||||
|
||||
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(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
|
||||
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;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Replace incremental channel-name counter with idempotent recomputation.
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
PERFORM shine_refresh_user_stats(NEW.owner_login);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Rebuild connection state without incremental user counters; then recompute affected users.
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_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 resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||
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')));
|
||||
ELSE
|
||||
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 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 WHEN 81 THEN 80 ELSE NULL END;
|
||||
IF positive_rel_type IS NULL THEN RETURN NEW; 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'));
|
||||
END IF;
|
||||
|
||||
PERFORM shine_refresh_user_stats(NEW.login);
|
||||
PERFORM shine_refresh_user_stats(resolved_login);
|
||||
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
|
||||
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE previous_sub_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
|
||||
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
|
||||
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
|
||||
|
||||
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
|
||||
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 LIMIT 1;
|
||||
|
||||
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||
UPDATE message_stats SET likes_count=likes_count+1,
|
||||
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) 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;
|
||||
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) 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;
|
||||
END IF;
|
||||
|
||||
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
|
||||
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Initial projection/rebuild.
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
|
||||
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT 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(*)::INT 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));
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,18,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,83 @@
|
||||
BEGIN;
|
||||
|
||||
-- v19: skip user_stats refresh for logins that are absent from solana_user_pda_current.
|
||||
-- This keeps bootstrap resilient to legacy rows that are still present in blocks.
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = p_login
|
||||
LIMIT 1
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||
following_channels_count, close_friends_count, friends_count,
|
||||
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||
SELECT p_login,
|
||||
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||
0,
|
||||
(SELECT COUNT(*)::INT 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(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||
v_now
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||
following_users_count=0,
|
||||
following_channels_count=EXCLUDED.following_channels_count,
|
||||
close_friends_count=EXCLUDED.close_friends_count,
|
||||
friends_count=EXCLUDED.friends_count,
|
||||
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
PERFORM shine_refresh_user_stats(p_login);
|
||||
FOR r IN
|
||||
SELECT DISTINCT x.login FROM (
|
||||
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
UNION
|
||||
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
) x
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_stats(r.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Notifications v2: three categories + signed seen watermarks.
|
||||
|
||||
ALTER TABLE user_notifications_state
|
||||
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
|
||||
ALTER TABLE user_notifications_state
|
||||
ADD CONSTRAINT user_notifications_state_notification_kind_check
|
||||
CHECK (notification_kind IN ('reply', 'connection', 'event'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
owner_login VARCHAR(60) NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
signed_blob BYTEA NOT NULL,
|
||||
signed_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Channel unread/read projection.
|
||||
-- The row is created only by a signed SetChannelReadState from the client.
|
||||
-- New channel publications only increment existing rows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 21 WHERE id = 1;
|
||||
@@ -0,0 +1,56 @@
|
||||
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
|
||||
blockchain_name TEXT PRIMARY KEY,
|
||||
last_archived_source_block_number BIGINT NOT NULL,
|
||||
last_archived_source_block_hash BYTEA NOT NULL,
|
||||
last_archive_big_block_number BIGINT NOT NULL,
|
||||
last_archive_big_block_hash BYTEA NOT NULL,
|
||||
last_chunk_offset BIGINT NOT NULL,
|
||||
last_chunk_size BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
big_block_number BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
local_archive_path TEXT,
|
||||
archive_hash BYTEA,
|
||||
arweave_tx_id BYTEA,
|
||||
arweave_confirmations INTEGER,
|
||||
solana_signature TEXT,
|
||||
error_text TEXT,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
|
||||
ON archive_publish_job(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
|
||||
ON archive_publish_job(big_block_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
|
||||
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
|
||||
blockchain_name TEXT NOT NULL,
|
||||
from_source_block_number BIGINT NOT NULL,
|
||||
to_source_block_number BIGINT NOT NULL,
|
||||
previous_source_block_hash BYTEA NOT NULL,
|
||||
last_source_block_hash BYTEA NOT NULL,
|
||||
previous_archive_big_block_number BIGINT,
|
||||
previous_archive_big_block_hash BYTEA,
|
||||
previous_chunk_offset BIGINT,
|
||||
previous_chunk_size BIGINT,
|
||||
new_chunk_offset BIGINT,
|
||||
new_chunk_size BIGINT,
|
||||
PRIMARY KEY (job_id, blockchain_name)
|
||||
);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_history
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_history
|
||||
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- v23: trusted SHINE-ARCHIVE import state + last chunk location index.
|
||||
-- Solana archive head itself is already copied by the normal Solana users sync (v22).
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
ON solana_user_pda_current(is_server, archive_imported)
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
|
||||
blockchain_name TEXT PRIMARY KEY,
|
||||
publisher_login TEXT NOT NULL,
|
||||
arweave_tx_id TEXT NOT NULL,
|
||||
archive_hash BYTEA NOT NULL,
|
||||
big_block_number BIGINT NOT NULL,
|
||||
chunk_offset BIGINT NOT NULL,
|
||||
chunk_size BIGINT NOT NULL,
|
||||
source_last_block_number BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher
|
||||
ON archive_blockchain_location(publisher_login);
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 23,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,47 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
setting_type INTEGER NOT NULL,
|
||||
setting_key TEXT NOT NULL,
|
||||
time_ms BIGINT NOT NULL,
|
||||
value_text TEXT NOT NULL DEFAULT '',
|
||||
value_num BIGINT NOT NULL DEFAULT 0,
|
||||
client_key TEXT NOT NULL DEFAULT '',
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (login, setting_type, setting_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||
ON user_settings(login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
|
||||
ON user_settings(login, synced, time_ms);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
remote_server_login TEXT NOT NULL,
|
||||
remote_server_url TEXT NOT NULL,
|
||||
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
cursor_setting_key TEXT NOT NULL DEFAULT '',
|
||||
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_sync_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, remote_server_login)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
|
||||
ON user_settings_sync_peer_state(owner_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 9, 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;
|
||||
File diff suppressed because it is too large
Load Diff
+61
@@ -4,6 +4,8 @@ import org.eclipse.jetty.websocket.api.Session;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* ConnectionContext — контекст состояния одного WebSocket-соединения.
|
||||
* Живёт ровно столько же, сколько живёт подключение.
|
||||
@@ -25,6 +27,11 @@ public class ConnectionContext {
|
||||
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
|
||||
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
|
||||
|
||||
// При успешной авторизации отдельно фиксируем отношение пользователя к этому серверу.
|
||||
public static final int USER_SERVER_SCOPE_NONE = 0;
|
||||
public static final int USER_SERVER_SCOPE_LOCAL = 1;
|
||||
public static final int USER_SERVER_SCOPE_REMOTE = 2;
|
||||
|
||||
// Полный пользователь из runtime БД (current users / solana_user_pda_current)
|
||||
private CurrentUserEntry currentUserEntry;
|
||||
|
||||
@@ -71,12 +78,24 @@ public class ConnectionContext {
|
||||
*/
|
||||
private int authenticationStatus = AUTH_STATUS_NONE;
|
||||
|
||||
/**
|
||||
* LOCAL — этот сервер является первым access server пользователя.
|
||||
* REMOTE — пользователь криптографически авторизован, но его access server другой.
|
||||
*/
|
||||
private int userServerScope = USER_SERVER_SCOPE_NONE;
|
||||
|
||||
/**
|
||||
* WebSocket-сессия Jetty для данного подключения.
|
||||
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
|
||||
*/
|
||||
private Session wsSession;
|
||||
|
||||
/** Временная server-to-server роль, заявленная через ServerHello. */
|
||||
private boolean serverConnection;
|
||||
private String remoteServerLogin;
|
||||
private int remoteServerProtocolVersion;
|
||||
private List<String> remoteServerCapabilities = List.of();
|
||||
|
||||
// --- WebSocket Session ---
|
||||
|
||||
public Session getWsSession() {
|
||||
@@ -87,6 +106,20 @@ public class ConnectionContext {
|
||||
this.wsSession = wsSession;
|
||||
}
|
||||
|
||||
public boolean isServerConnection() { return serverConnection; }
|
||||
public void setServerConnection(boolean serverConnection) { this.serverConnection = serverConnection; }
|
||||
|
||||
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||
|
||||
public int getRemoteServerProtocolVersion() { return remoteServerProtocolVersion; }
|
||||
public void setRemoteServerProtocolVersion(int value) { this.remoteServerProtocolVersion = value; }
|
||||
|
||||
public List<String> getRemoteServerCapabilities() { return remoteServerCapabilities; }
|
||||
public void setRemoteServerCapabilities(List<String> capabilities) {
|
||||
this.remoteServerCapabilities = capabilities == null ? List.of() : List.copyOf(capabilities);
|
||||
}
|
||||
|
||||
// --- SolanaUser / ActiveSession ---
|
||||
|
||||
public CurrentUserEntry getCurrentUser() {
|
||||
@@ -175,6 +208,28 @@ public class ConnectionContext {
|
||||
return authenticationStatus == AUTH_STATUS_NONE;
|
||||
}
|
||||
|
||||
public int getUserServerScope() {
|
||||
return userServerScope;
|
||||
}
|
||||
|
||||
public void setUserServerScope(int userServerScope) {
|
||||
this.userServerScope = userServerScope;
|
||||
}
|
||||
|
||||
public boolean isLocalAuthenticatedUser() {
|
||||
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_LOCAL;
|
||||
}
|
||||
|
||||
public boolean isRemoteAuthenticatedUser() {
|
||||
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_REMOTE;
|
||||
}
|
||||
|
||||
public String getUserServerScopeName() {
|
||||
return userServerScope == USER_SERVER_SCOPE_LOCAL ? "LOCAL"
|
||||
: userServerScope == USER_SERVER_SCOPE_REMOTE ? "REMOTE"
|
||||
: "NONE";
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
currentUserEntry = null;
|
||||
activeSessionEntry = null;
|
||||
@@ -187,7 +242,12 @@ public class ConnectionContext {
|
||||
sessionLoginNonceExpiresAtMs = 0;
|
||||
|
||||
authenticationStatus = AUTH_STATUS_NONE;
|
||||
userServerScope = USER_SERVER_SCOPE_NONE;
|
||||
wsSession = null;
|
||||
serverConnection = false;
|
||||
remoteServerLogin = null;
|
||||
remoteServerProtocolVersion = 0;
|
||||
remoteServerCapabilities = List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -196,6 +256,7 @@ public class ConnectionContext {
|
||||
"login='" + getLogin() + '\'' +
|
||||
", sessionId=" + sessionId +
|
||||
", authenticationStatus=" + authenticationStatus +
|
||||
", userServerScope=" + getUserServerScopeName() +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
+56
-16
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ResolveLoginForAuth_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
|
||||
@@ -22,6 +23,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_
|
||||
|
||||
// --- auth entities ---
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
|
||||
@@ -40,15 +42,13 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
|
||||
|
||||
// --- NEW: SearchUsers ---
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
||||
@@ -60,6 +60,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
|
||||
|
||||
// --- NEW: connections friends lists ---
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||
@@ -67,34 +73,46 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileRelations_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileChannels_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
@@ -103,11 +121,9 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
|
||||
@@ -121,6 +137,7 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ServerHello_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
|
||||
@@ -129,6 +146,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -145,10 +163,9 @@ public final class JsonHandlerRegistry {
|
||||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
||||
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
|
||||
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
|
||||
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
||||
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
|
||||
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
|
||||
@@ -176,34 +193,45 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||
Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
|
||||
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
|
||||
|
||||
// --- userSettings ---
|
||||
Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()),
|
||||
Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()),
|
||||
Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
|
||||
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
|
||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
@@ -211,6 +239,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("SendSignal", new Net_SendSignal_Handler()),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("ServerHello", new Net_ServerHello_Handler()),
|
||||
Map.entry("Ping", new Net_Ping_Handler()),
|
||||
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
||||
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
|
||||
@@ -227,10 +256,9 @@ public final class JsonHandlerRegistry {
|
||||
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
|
||||
Map.entry("GetUser", Net_GetUser_Request.class),
|
||||
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
||||
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
|
||||
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
|
||||
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
||||
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
|
||||
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
|
||||
@@ -258,34 +286,45 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||
Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
|
||||
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
|
||||
|
||||
// --- userSettings ---
|
||||
Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class),
|
||||
Map.entry("GetUserSetting", Net_GetUserSetting_Request.class),
|
||||
Map.entry("ListUserSettings", Net_ListUserSettings_Request.class),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
|
||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
|
||||
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
||||
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
|
||||
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
@@ -293,6 +332,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("SendSignal", Net_SendSignal_Request.class),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("ServerHello", Net_ServerHello_Request.class),
|
||||
Map.entry("Ping", Net_Ping_Request.class),
|
||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
||||
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
|
||||
|
||||
+16
@@ -205,6 +205,22 @@ public final class JsonInboundProcessor {
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200));
|
||||
}
|
||||
|
||||
// REMOTE-пользователь успешно авторизован, но этот сервер не является
|
||||
// его access server. Такой контекст нужен для realtime/call signaling,
|
||||
// однако обычные homeserver-операции здесь запрещены централизованно.
|
||||
if (ctx != null
|
||||
&& ctx.isRemoteAuthenticatedUser()
|
||||
&& !RemoteUserOperationPolicy.isAllowed(op)) {
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op,
|
||||
requestId,
|
||||
403,
|
||||
"USER_NOT_LOCAL",
|
||||
"Пользователь авторизован, но относится к другому access server"
|
||||
);
|
||||
return writeResponse(err);
|
||||
}
|
||||
|
||||
// 5) Вызываем хэндлер
|
||||
Net_Response response;
|
||||
try {
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package server.logic.ws_protocol.JSON;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* REMOTE-пользователь полностью авторизован криптографически, но этот сервер
|
||||
* не является его access server. Поэтому ему разрешён только минимальный набор
|
||||
* realtime/звонковых операций.
|
||||
*/
|
||||
public final class RemoteUserOperationPolicy {
|
||||
|
||||
private static final Set<String> ALLOWED = Set.of(
|
||||
"Ping",
|
||||
"GetServerInfo",
|
||||
"GetCallIceConfig",
|
||||
"CallInviteBroadcast",
|
||||
"CallSignalToSession",
|
||||
"SendSignal",
|
||||
"CallDeliveryReport",
|
||||
"ClientErrorLog",
|
||||
"ClientDebugLog",
|
||||
"CloseActiveSession",
|
||||
"ResolveLoginForAuth"
|
||||
);
|
||||
|
||||
private RemoteUserOperationPolicy() {}
|
||||
|
||||
public static boolean isAllowed(String op) {
|
||||
return op != null && ALLOWED.contains(op);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Единая серверная логика определения, является ли текущий сервер
|
||||
* домашним access server для пользователя.
|
||||
*/
|
||||
public final class AuthAccessScopeSupport {
|
||||
|
||||
public static final String RESOLUTION_LOCAL = "LOCAL";
|
||||
public static final String RESOLUTION_REMOTE = "REMOTE";
|
||||
public static final String RESOLUTION_NOT_FOUND = "NOT_FOUND";
|
||||
public static final String RESOLUTION_NO_ACCESS_SERVER = "NO_ACCESS_SERVER";
|
||||
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
|
||||
private AuthAccessScopeSupport() {}
|
||||
|
||||
public static Resolution resolveLogin(String login) throws SQLException {
|
||||
String cleanLogin = login == null ? "" : login.trim();
|
||||
if (cleanLogin.isBlank()) {
|
||||
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
|
||||
}
|
||||
|
||||
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(cleanLogin);
|
||||
if (user == null) {
|
||||
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
|
||||
}
|
||||
|
||||
UserAccessServerRouteEntry route =
|
||||
UserAccessServersCurrentDAO.getInstance().findPrimaryByUserLogin(user.getLogin());
|
||||
if (route == null) {
|
||||
return new Resolution(RESOLUTION_NO_ACCESS_SERVER, user, null, null);
|
||||
}
|
||||
|
||||
String ownServerLogin = currentServerLogin();
|
||||
String routeServerLogin = normalize(route.getServerLogin());
|
||||
String resolution = ownServerLogin != null && ownServerLogin.equals(routeServerLogin)
|
||||
? RESOLUTION_LOCAL
|
||||
: RESOLUTION_REMOTE;
|
||||
|
||||
return new Resolution(resolution, user, route, ownServerLogin);
|
||||
}
|
||||
|
||||
public static int connectionScopeForLogin(String login) throws SQLException {
|
||||
Resolution resolution = resolveLogin(login);
|
||||
return RESOLUTION_LOCAL.equals(resolution.resolution())
|
||||
? ConnectionContext.USER_SERVER_SCOPE_LOCAL
|
||||
: ConnectionContext.USER_SERVER_SCOPE_REMOTE;
|
||||
}
|
||||
|
||||
public static String connectionScopeName(int scope) {
|
||||
return scope == ConnectionContext.USER_SERVER_SCOPE_LOCAL ? "LOCAL" : "REMOTE";
|
||||
}
|
||||
|
||||
public static String currentServerLogin() {
|
||||
return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
public record Resolution(
|
||||
String resolution,
|
||||
CurrentUserEntry user,
|
||||
UserAccessServerRouteEntry primaryAccessServer,
|
||||
String currentServerLogin
|
||||
) {}
|
||||
}
|
||||
+1
@@ -71,6 +71,7 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
ctx.setCurrentUser(solanaUserEntry);
|
||||
ctx.setUserServerScope(AuthAccessScopeSupport.connectionScopeForLogin(solanaUserEntry.getLogin()));
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
|
||||
|
||||
byte[] buf = new byte[32];
|
||||
|
||||
+6
@@ -414,10 +414,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
return err;
|
||||
}
|
||||
|
||||
// Между AuthChallenge и CreateAuthSession routing мог измениться,
|
||||
// поэтому окончательный LOCAL/REMOTE scope вычисляем повторно.
|
||||
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(canonicalLogin);
|
||||
|
||||
// --- обновляем контекст ---
|
||||
ctx.setActiveSession(activeSessionEntry);
|
||||
ctx.setSessionId(sessionId);
|
||||
ctx.setAuthNonce(null);
|
||||
ctx.setUserServerScope(userServerScope);
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||
|
||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||
@@ -428,6 +433,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setSessionId(sessionId);
|
||||
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
|
||||
public class Net_ResolveLoginForAuth_Handler implements JsonMessageHandler {
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||
Net_ResolveLoginForAuth_Request req = (Net_ResolveLoginForAuth_Request) baseReq;
|
||||
String login = req.getLogin() == null ? "" : req.getLogin().trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login");
|
||||
}
|
||||
|
||||
AuthAccessScopeSupport.Resolution resolved = AuthAccessScopeSupport.resolveLogin(login);
|
||||
|
||||
Net_ResolveLoginForAuth_Response resp = new Net_ResolveLoginForAuth_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setResolution(resolved.resolution());
|
||||
|
||||
CurrentUserEntry user = resolved.user();
|
||||
if (user != null) {
|
||||
resp.setLogin(user.getLogin());
|
||||
}
|
||||
|
||||
UserAccessServerRouteEntry route = resolved.primaryAccessServer();
|
||||
if (route != null) {
|
||||
resp.setAccessServerLogin(route.getServerLogin());
|
||||
resp.setAccessServerUrl(route.getServerUrl());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
+4
@@ -292,10 +292,13 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
session.setClientPlatform(clientPlatform);
|
||||
session.setUserLanguage(userLanguage);
|
||||
|
||||
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(user.getLogin());
|
||||
|
||||
// ctx
|
||||
ctx.setActiveSession(session);
|
||||
ctx.setCurrentUser(user);
|
||||
ctx.setSessionId(sessionId);
|
||||
ctx.setUserServerScope(userServerScope);
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||
|
||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||
@@ -306,6 +309,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setStoragePwd(session.getStoragePwd());
|
||||
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -66,6 +66,16 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
||||
}
|
||||
String canonicalLogin = user.getLogin();
|
||||
|
||||
AuthAccessScopeSupport.Resolution authResolution = AuthAccessScopeSupport.resolveLogin(canonicalLogin);
|
||||
if (!AuthAccessScopeSupport.RESOLUTION_LOCAL.equals(authResolution.resolution())) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
409,
|
||||
"USER_NOT_LOCAL",
|
||||
"Вход через доверенное устройство нужно начинать на access server пользователя"
|
||||
);
|
||||
}
|
||||
|
||||
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
|
||||
boolean enabled = settings == null || settings.isEnabled();
|
||||
if (!enabled) {
|
||||
@@ -100,14 +110,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
||||
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
||||
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
|
||||
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||
if (approverConnections.isEmpty()) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
422,
|
||||
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
|
||||
"Нет ни одной активной доверенной сессии пользователя в сети"
|
||||
);
|
||||
}
|
||||
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||
canonicalLogin,
|
||||
requesterSessionKey,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ public final class SolanaUserPdaImportService {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
|
||||
|
||||
/** Идентификатор сессии, base64 от 32 байт. */
|
||||
private String sessionId;
|
||||
private String connectionScope;
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
@@ -30,4 +31,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public String getConnectionScope() { return connectionScope; }
|
||||
public void setConnectionScope(String connectionScope) { this.connectionScope = connectionScope; }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
/** Первый шаг UI-входа: определить существование пользователя и его access server. */
|
||||
public class Net_ResolveLoginForAuth_Request extends Net_Request {
|
||||
private String login;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
/**
|
||||
* Ответ ResolveLoginForAuth.
|
||||
*
|
||||
* resolution:
|
||||
* - LOCAL пользователь существует и относится к этому серверу;
|
||||
* - REMOTE пользователь существует, но его первый access server другой;
|
||||
* - NOT_FOUND пользователя нет в текущем Solana PDA snapshot;
|
||||
* - NO_ACCESS_SERVER пользователь существует, но корректный access server не найден.
|
||||
*/
|
||||
public class Net_ResolveLoginForAuth_Response extends Net_Response {
|
||||
private String resolution;
|
||||
private String login;
|
||||
private String accessServerLogin;
|
||||
private String accessServerUrl;
|
||||
|
||||
public String getResolution() { return resolution; }
|
||||
public void setResolution(String resolution) { this.resolution = resolution; }
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getAccessServerLogin() { return accessServerLogin; }
|
||||
public void setAccessServerLogin(String accessServerLogin) { this.accessServerLogin = accessServerLogin; }
|
||||
|
||||
public String getAccessServerUrl() { return accessServerUrl; }
|
||||
public void setAccessServerUrl(String accessServerUrl) { this.accessServerUrl = accessServerUrl; }
|
||||
}
|
||||
+4
@@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
public class Net_SessionLogin_Response extends Net_Response {
|
||||
|
||||
private String storagePwd;
|
||||
private String connectionScope;
|
||||
|
||||
public String getStoragePwd() {
|
||||
return storagePwd;
|
||||
@@ -17,4 +18,7 @@ public class Net_SessionLogin_Response extends Net_Response {
|
||||
public void setStoragePwd(String storagePwd) {
|
||||
this.storagePwd = storagePwd;
|
||||
}
|
||||
|
||||
public String getConnectionScope() { return connectionScope; }
|
||||
public void setConnectionScope(String connectionScope) { this.connectionScope = connectionScope; }
|
||||
}
|
||||
+156
-5
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.UserParamBody;
|
||||
import org.slf4j.Logger;
|
||||
@@ -24,20 +26,24 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -61,7 +67,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(
|
||||
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
|
||||
|
||||
public Net_AddBlock_Handler() {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
@@ -95,7 +102,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||
req.getBlockBytesB64()
|
||||
req.getBlockBytesB64(),
|
||||
true
|
||||
);
|
||||
|
||||
// ✅ УСПЕХ: как раньше
|
||||
@@ -181,7 +189,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
String blockchainName,
|
||||
int globalNumberFromReq,
|
||||
String prevGlobalHashHexFromReq,
|
||||
String blockBytesB64
|
||||
String blockBytesB64,
|
||||
boolean replicateAfterWrite
|
||||
) {
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||||
@@ -558,6 +567,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setEditedByBlockNumber(be.getToBlockNumber());
|
||||
}
|
||||
|
||||
if (block.body instanceof ConnectionBody connectionBody) {
|
||||
String votingError = validateConnectionVotingRights(login, block.subType & 0xFFFF, connectionBody.toLogin());
|
||||
if (votingError != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, votingError, serverLastNum, serverLastHashHex);
|
||||
}
|
||||
}
|
||||
|
||||
UserParamEntry upsertedParam = null;
|
||||
if (block.body instanceof UserParamBody upBody) {
|
||||
String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank())
|
||||
@@ -573,7 +589,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
for (String changedLogin : counterChangedLogins) {
|
||||
UserCountersSupport.pushChanged(changedLogin);
|
||||
}
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -593,15 +615,77 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||||
login, blockchainName, block.blockNumber, newHashHex);
|
||||
|
||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||
if (replicateAfterWrite) {
|
||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||
}
|
||||
|
||||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
|
||||
* но не запускает повторную межсерверную репликацию уже импортированного блока.
|
||||
*/
|
||||
public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) {
|
||||
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||
return new ArchiveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
}
|
||||
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
|
||||
return new ArchiveImportResult(false, "empty_block", -1, "");
|
||||
}
|
||||
final BchBlockEntry parsed;
|
||||
try {
|
||||
parsed = new BchBlockEntry(rawBlockBytes);
|
||||
} catch (Exception e) {
|
||||
return new ArchiveImportResult(false, "bad_block_format", -1, "");
|
||||
}
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
try {
|
||||
AddBlockResult r = addBlock(
|
||||
blockchainName,
|
||||
parsed.blockNumber,
|
||||
toHex(parsed.prevHash32),
|
||||
Base64Ws.encode(rawBlockBytes),
|
||||
false
|
||||
);
|
||||
return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Helpers ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||
if (targetLogin == null || targetLogin.isBlank()) return "bad_connection_target";
|
||||
String sql = subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
? "SELECT shine_is_primary(?), shine_target_accepts_primary_confirmation(?)"
|
||||
: "SELECT (shine_is_primary(?) AND shine_is_shining(?)), shine_target_accepts_shine_confirmation(?)";
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
if (subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)) {
|
||||
ps.setString(1, actorLogin); ps.setString(2, targetLogin);
|
||||
} else {
|
||||
ps.setString(1, actorLogin); ps.setString(2, actorLogin); ps.setString(3, targetLogin);
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next() || !rs.getBoolean(1)) return subType == 80 ? "primary_vote_not_allowed" : "shine_vote_not_allowed";
|
||||
if (!rs.getBoolean(2)) return subType == 80 ? "target_non_voting" : "target_shine_not_interested";
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Connection voting permission check failed actor={} target={} subtype={}", actorLogin, targetLogin, subType, e);
|
||||
return "voting_permission_check_failed";
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decodeBase64(String b64) {
|
||||
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
|
||||
return Base64Ws.decode(b64);
|
||||
@@ -855,6 +939,73 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return b;
|
||||
}
|
||||
|
||||
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
|
||||
if (block == null || storedEntry == null) return null;
|
||||
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
|
||||
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
long createdAtMs = block.timestamp * 1000L;
|
||||
String ownerLogin = storedEntry.getToLogin();
|
||||
String sourceLogin = storedEntry.getLogin();
|
||||
|
||||
// A user must never receive a notification for replying to their own message/thread.
|
||||
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
|
||||
|
||||
if (msgType == 1
|
||||
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
&& block.body instanceof TextReplyBody replyBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("reply");
|
||||
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||
if (personalConnection || channelEvent) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
|
||||
String ownerLogin,
|
||||
long createdAtMs,
|
||||
int msgSubType) {
|
||||
UserNotificationEntry entry = new UserNotificationEntry();
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setCreatedAtMs(createdAtMs);
|
||||
entry.setSourceLogin(storedEntry.getLogin());
|
||||
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
|
||||
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
|
||||
entry.setSourceBlockHash(storedEntry.getBlockHash());
|
||||
entry.setTargetLogin(storedEntry.getToLogin());
|
||||
entry.setTargetBchName(storedEntry.getToBchName());
|
||||
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
|
||||
entry.setTargetBlockHash(storedEntry.getToBlockHash());
|
||||
entry.setSourceMsgSubType(msgSubType);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private String blockchainNameFromEntry(BlockEntry entry) {
|
||||
return entry == null ? null : entry.getBchName();
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
|
||||
+55
-3
@@ -4,11 +4,15 @@ import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -17,6 +21,8 @@ import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
@@ -41,25 +47,30 @@ public final class BlockchainWriter {
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
UserParamsDAO userParamsDAO,
|
||||
ChannelNameStateDAO channelNameStateDAO) {
|
||||
ChannelNameStateDAO channelNameStateDAO,
|
||||
UserNotificationsStateDAO userNotificationsStateDAO) {
|
||||
this.blocksDAO = blocksDAO;
|
||||
this.stateDAO = stateDAO;
|
||||
this.userParamsDAO = userParamsDAO;
|
||||
this.channelNameStateDAO = channelNameStateDAO;
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelMetaUpdateEntry,
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -69,6 +80,7 @@ public final class BlockchainWriter {
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
@@ -96,6 +108,35 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||
counterChangedLogins.add(be.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
@@ -130,6 +171,17 @@ public final class BlockchainWriter {
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||
if (msgType != 1) return false;
|
||||
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.archive.ArchiveBlockchainLocation;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.dao.ArchiveImportDAO;
|
||||
import shine.db.dao.ArchivePublicationDAO;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler {
|
||||
@Override public Net_Response handle(Net_Request base, ConnectionContext ctx) {
|
||||
Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base;
|
||||
String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim();
|
||||
if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен");
|
||||
try {
|
||||
ArchiveImportDAO importDao = ArchiveImportDAO.getInstance();
|
||||
ArchiveBlockchainLocation e=importDao.getLocation(bch);
|
||||
if (e == null) {
|
||||
// Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index.
|
||||
ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance();
|
||||
ArchiveChainCursor cursor = publicationDao.getCursor(bch);
|
||||
if (cursor != null) {
|
||||
ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream()
|
||||
.filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber())
|
||||
.findFirst().orElse(null);
|
||||
if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) {
|
||||
String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim();
|
||||
String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId());
|
||||
e = new ArchiveBlockchainLocation(
|
||||
bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
|
||||
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis());
|
||||
importDao.upsertLocation(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу");
|
||||
Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response();
|
||||
r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK);
|
||||
r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash()));
|
||||
r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r;
|
||||
} catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); }
|
||||
}
|
||||
private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request {
|
||||
private String blockchainName;
|
||||
public String getBlockchainName(){return blockchainName;}
|
||||
public void setBlockchainName(String value){this.blockchainName=value;}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response {
|
||||
private String blockchainName,publisherLogin,arweaveTxId,archiveHash;
|
||||
private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber;
|
||||
public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;}
|
||||
public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;}
|
||||
public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;}
|
||||
public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;}
|
||||
public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;}
|
||||
public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;}
|
||||
public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;}
|
||||
public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;}
|
||||
}
|
||||
+11
@@ -138,6 +138,17 @@ public final class ChannelNamesStateBootstrapper {
|
||||
dao.insertAll(c, entries);
|
||||
|
||||
applyMetaUpdates(c, dao, entries, skipped);
|
||||
// v18: counters are a projection, so rebuild them from current state after a full channel-name bootstrap.
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT shine_refresh_channel_follow_stats(owner_bch_name, channel_root_block_number, channel_root_block_hash)
|
||||
FROM channel_names_state
|
||||
WHERE channel_type_code = 1
|
||||
""")) {
|
||||
ps.execute();
|
||||
}
|
||||
c.commit();
|
||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||
if (!conflicts.isEmpty()) {
|
||||
|
||||
+17
-4
@@ -144,6 +144,14 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||
|| channelName == null || channelName.isBlank()) return 0;
|
||||
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
@@ -228,12 +236,13 @@ final class ChannelsReadSupport {
|
||||
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
boolean bounded = limit > 0;
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
""" + order + (bounded ? " LIMIT ?" : "");
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
@@ -244,7 +253,7 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||
ps.setInt(9, lineCode);
|
||||
ps.setInt(10, limit);
|
||||
if (bounded) ps.setInt(10, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
@@ -308,8 +317,10 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
|
||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
int likesCount = 0;
|
||||
int primaryLikesCount = 0;
|
||||
int shiningLikesCount = 0;
|
||||
int repliesCount = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
@@ -318,6 +329,8 @@ final class ChannelsReadSupport {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
likesCount = rs.getInt("likes_count");
|
||||
primaryLikesCount = rs.getInt("primary_likes_count");
|
||||
shiningLikesCount = rs.getInt("shining_likes_count");
|
||||
repliesCount = rs.getInt("replies_count");
|
||||
}
|
||||
}
|
||||
@@ -344,7 +357,7 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
}
|
||||
return new int[] {likesCount, repliesCount, ratingsCount};
|
||||
return new int[] {likesCount, repliesCount, ratingsCount, primaryLikesCount, shiningLikesCount};
|
||||
}
|
||||
|
||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||
|
||||
+35
-4
@@ -16,6 +16,8 @@ import utils.blockchain.BlockchainNameUtil;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -32,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
// limit is optional. null/0 means "return the whole channel".
|
||||
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||
if (limit < 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||
}
|
||||
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
@@ -65,6 +69,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||
channel.setChannelTypeCode(meta.channelTypeCode);
|
||||
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
||||
channel.setSubscribersCount(loadSubscribersCount(c, ownerBch, lineCode, meta.channelTypeCode));
|
||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
rootRef.setBlockNumber(lineCode);
|
||||
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
|
||||
@@ -110,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
if (!asc) {
|
||||
java.util.Collections.reverse(posts);
|
||||
}
|
||||
if (posts.size() > limit) {
|
||||
if (limit > 0 && posts.size() > limit) {
|
||||
posts = new ArrayList<>(posts.subList(0, limit));
|
||||
}
|
||||
}
|
||||
@@ -166,6 +171,8 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setPrimaryLikesCount(stats[3]);
|
||||
item.setShiningLikesCount(stats[4]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
item.setRatingsCount(stats[2]);
|
||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||
@@ -180,4 +187,28 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private static int loadSubscribersCount(Connection c, String ownerBch, int rootNumber, int channelTypeCode) {
|
||||
if (channelTypeCode != (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||
return 0;
|
||||
}
|
||||
String sql = """
|
||||
SELECT subscribers_count
|
||||
FROM channel_stats_state
|
||||
WHERE owner_bch_name = ?
|
||||
AND channel_root_block_number = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, rootNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return 0;
|
||||
return Math.max(0, rs.getInt("subscribers_count"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("GetChannelMessages: не удалось загрузить subscribers_count для {}#{}", ownerBch, rootNumber, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
|
||||
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
|
||||
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|
||||
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|
||||
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
}
|
||||
|
||||
final byte[] blockHash;
|
||||
try {
|
||||
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
if (blockHash == null || blockHash.length == 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String sql = """
|
||||
SELECT rs.from_login,
|
||||
COALESCE(ups.first_name, '') AS first_name,
|
||||
COALESCE(ups.last_name, '') AS last_name,
|
||||
COALESCE(ups.ava_ar, '') AS avatar_ar,
|
||||
COALESCE(ups.account_role, 'unknown') AS account_role,
|
||||
COALESCE(ups.shine_status, 'unknown') AS shine_status
|
||||
FROM reactions_state rs
|
||||
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
|
||||
WHERE rs.reaction_type = 1
|
||||
AND rs.last_sub_type = 1
|
||||
AND rs.to_bch_name = ?
|
||||
AND rs.to_block_number = ?
|
||||
AND rs.to_block_hash = ?
|
||||
ORDER BY LOWER(rs.from_login)
|
||||
""";
|
||||
|
||||
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, message.getBlockchainName().trim());
|
||||
ps.setInt(2, message.getBlockNumber());
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||
user.setLogin(rs.getString("from_login"));
|
||||
user.setFirstName(rs.getString("first_name"));
|
||||
user.setLastName(rs.getString("last_name"));
|
||||
user.setAvatarAr(rs.getString("avatar_ar"));
|
||||
|
||||
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
|
||||
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
|
||||
if (isOfficial && isShining) shining.add(user);
|
||||
else if (isOfficial) official.add(user);
|
||||
else others.add(user);
|
||||
}
|
||||
|
||||
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setShining(shining);
|
||||
resp.setOfficial(official);
|
||||
resp.setOthers(others);
|
||||
resp.setTotal(shining.size() + official.size() + others.size());
|
||||
resp.setTruncated(false);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("GetMessageLikes failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -242,6 +242,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setPrimaryLikesCount(stats[3]);
|
||||
node.setShiningLikesCount(stats[4]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
node.setRatingsCount(stats[2]);
|
||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
String login = ctx.getLogin().trim();
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
|
||||
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
|
||||
r.setOp(req.getOp());
|
||||
r.setRequestId(req.getRequestId());
|
||||
r.setStatus(WireCodes.Status.OK);
|
||||
r.setLogin(login);
|
||||
r.setDmUnreadCount(s.dmUnreadCount());
|
||||
r.setChannelsUnreadCount(s.channelsUnreadCount());
|
||||
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
|
||||
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
|
||||
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
|
||||
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
|
||||
return r;
|
||||
} catch (Exception e) {
|
||||
log.error("GetUserCounters failed for {}", login, e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -81,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(0);
|
||||
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||
row.setMessagesCount(messagesCount);
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||
? null
|
||||
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||
row.setReadStateInitialized(readState != null);
|
||||
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user