diff --git a/SHiNE-bundle-20260829-180249.zip b/SHiNE-bundle-20260829-180249.zip new file mode 100644 index 00000000..193937b8 Binary files /dev/null and b/SHiNE-bundle-20260829-180249.zip differ diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md index 2cf116e6..cd7d0d9f 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -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` ## Деплой diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 85049ff6..1f571cdd 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -28,6 +28,11 @@ public final class DatabaseInitializer { 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 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"; @@ -39,6 +44,11 @@ public final class DatabaseInitializer { 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"; private DatabaseInitializer() {} @@ -142,6 +152,26 @@ public final class DatabaseInitializer { 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; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 2771a743..cb065341 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -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( @@ -366,6 +368,100 @@ 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(""" + 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); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDeliveryStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDeliveryStateDAO.java new file mode 100644 index 00000000..85fa83e3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDeliveryStateDAO.java @@ -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 listByOutgoingMessageKeys(List keys) throws SQLException { + Map 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 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 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) {} +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncOutboxDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncOutboxDAO.java new file mode 100644 index 00000000..3bc5c193 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncOutboxDAO.java @@ -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 listUnsynced(String ownerLogin, int limit) throws SQLException { + return listUnsynced(ownerLogin, 0L, "", limit); + } + + public List 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 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 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 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 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; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java index e95a6df4..8b8fc312 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java @@ -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 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 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 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")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java index 82431ef9..7fb87514 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java @@ -12,15 +12,11 @@ import java.util.ArrayList; import java.util.List; /** - * UserSettingsDAO — хранение пользовательских настроек. - * - * Правило: - * - уникальность: login + setting_type + setting_key - * - запись обновляется только если time_ms новее - * - synced=true означает, что значение уже дошло до второго сервера + * Локальное хранение подписанных пользовательских настроек. + * Запись уникальна по login + setting_type + setting_key и обновляется только + * более новым time_ms. Межсерверной репликации настроек нет. */ public final class UserSettingsDAO { - private static volatile UserSettingsDAO instance; private final DbController db = DbController.getInstance(); @@ -39,16 +35,15 @@ public final class UserSettingsDAO { String sql = """ INSERT INTO user_settings ( login, setting_type, setting_key, time_ms, - value_text, value_num, client_key, signature, synced - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + 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, - synced = EXCLUDED.synced + signature = EXCLUDED.signature WHERE user_settings.time_ms < EXCLUDED.time_ms """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -58,27 +53,20 @@ public final class UserSettingsDAO { ps.setLong(4, e.getTimeMs()); ps.setString(5, e.getValueText() == null ? "" : e.getValueText()); ps.setLong(6, e.getValueNum()); - if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR); else ps.setString(7, e.getClientKey()); - if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR); else ps.setString(8, e.getSignature()); - - ps.setBoolean(9, e.isSynced()); return ps.executeUpdate(); } } - public int upsertIfNewer(UserSettingEntry e) throws SQLException { - try (Connection c = db.getConnection()) { - return upsertIfNewer(c, e); - } - } - - public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException { + public UserSettingEntry getByLoginTypeKey( + Connection c, String login, int settingType, String settingKey + ) throws SQLException { String sql = """ - SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced + 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 @@ -93,15 +81,10 @@ public final class UserSettingsDAO { } } - public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException { - try (Connection c = db.getConnection()) { - return getByLoginTypeKey(c, login, settingType, settingKey); - } - } - public List getByLogin(Connection c, String login) throws SQLException { String sql = """ - SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced + 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 @@ -122,92 +105,6 @@ public final class UserSettingsDAO { } } - public List listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException { - String sql = """ - SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced - FROM user_settings - WHERE LOWER(login) = LOWER(?) - AND ( - time_ms > ? - OR (time_ms = ? AND setting_key > ?) - ) - ORDER BY time_ms ASC, setting_key ASC - LIMIT ? - """; - List out = new ArrayList<>(); - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - ps.setLong(2, Math.max(0L, afterTimeMs)); - ps.setLong(3, Math.max(0L, afterTimeMs)); - ps.setString(4, afterSettingKey == null ? "" : afterSettingKey); - ps.setInt(5, Math.max(1, limit)); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) out.add(mapRow(rs)); - } - } - return out; - } - - public List listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException { - String sql = """ - SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced - FROM user_settings - WHERE LOWER(login) = LOWER(?) - AND synced = FALSE - ORDER BY time_ms ASC, setting_key ASC - LIMIT ? - """; - List out = new ArrayList<>(); - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - ps.setInt(2, Math.max(1, limit)); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) out.add(mapRow(rs)); - } - } - return out; - } - - public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException { - String sql = """ - UPDATE user_settings - SET synced = TRUE - WHERE LOWER(login) = LOWER(?) - AND setting_type = ? - AND setting_key = ? - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - ps.setInt(2, settingType); - ps.setString(3, settingKey); - return ps.executeUpdate(); - } - } - - public int markAllUnsynced(Connection c, String login) throws SQLException { - String sql = """ - UPDATE user_settings - SET synced = FALSE - WHERE LOWER(login) = LOWER(?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - return ps.executeUpdate(); - } - } - - public int markAllUnsynced() throws SQLException { - try (Connection c = db.getConnection()) { - return markAllUnsynced(c); - } - } - - public int markAllUnsynced(Connection c) throws SQLException { - try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) { - return ps.executeUpdate(); - } - } - private static UserSettingEntry mapRow(ResultSet rs) throws SQLException { UserSettingEntry e = new UserSettingEntry(); e.setLogin(rs.getString("login")); @@ -216,16 +113,12 @@ public final class UserSettingsDAO { e.setTimeMs(rs.getLong("time_ms")); e.setValueText(rs.getString("value_text")); e.setValueNum(rs.getLong("value_num")); - String clientKey = rs.getString("client_key"); if (rs.wasNull()) clientKey = null; e.setClientKey(clientKey); - String signature = rs.getString("signature"); if (rs.wasNull()) signature = null; e.setSignature(signature); - - e.setSynced(rs.getBoolean("synced")); return e; } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmDeliveryStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmDeliveryStateEntry.java new file mode 100644 index 00000000..ff153962 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmDeliveryStateEntry.java @@ -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; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncOutboxEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncOutboxEntry.java new file mode 100644 index 00000000..e85cdb5b --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncOutboxEntry.java @@ -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; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java index 53fee088..aa60fb0e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java @@ -1,19 +1,6 @@ package shine.db.entities; -/** - * UserSettingEntry — одна пользовательская настройка. - * - * Таблица: user_settings - * - login TEXT NOT NULL - * - setting_type INTEGER NOT NULL - * - setting_key TEXT NOT NULL - * - time_ms BIGINT NOT NULL - * - value_text TEXT NOT NULL - * - value_num BIGINT NOT NULL - * - client_key TEXT NOT NULL - * - signature TEXT NOT NULL - * - synced BOOLEAN NOT NULL - */ +/** Одна локально сохранённая подписанная пользовательская настройка. */ public class UserSettingEntry { private String login; private int settingType; @@ -23,11 +10,19 @@ public class UserSettingEntry { private long valueNum; private String clientKey; private String signature; - private boolean synced; public UserSettingEntry() {} - public UserSettingEntry(String login, int settingType, String settingKey, long timeMs, String valueText, long valueNum, String clientKey, String signature, boolean synced) { + 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; @@ -36,33 +31,22 @@ public class UserSettingEntry { this.valueNum = valueNum; this.clientKey = clientKey; this.signature = signature; - this.synced = synced; } 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; } - - public boolean isSynced() { return synced; } - public void setSynced(boolean synced) { this.synced = synced; } } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v12.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v12.sql new file mode 100644 index 00000000..92224813 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v12.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v13.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v13.sql new file mode 100644 index 00000000..09928eba --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v13.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql new file mode 100644 index 00000000..177a3b13 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql new file mode 100644 index 00000000..341e2c22 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v16.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v16.sql new file mode 100644 index 00000000..9d138cfe --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v16.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 488e3d09..107c211a 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 11, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) + 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; @@ -177,7 +177,7 @@ BEGIN ELSE u.access_servers_json::jsonb END ) WITH ORDINALITY AS access_server(login_value, ord) - WHERE ord <= 2 + WHERE ord = 1 ) AS access_server JOIN solana_user_pda_current s ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) @@ -220,7 +220,7 @@ BEGIN ELSE u.access_servers_json::jsonb END ) WITH ORDINALITY AS access_server(login_value, ord) - WHERE ord <= 2 + WHERE ord = 1 ) AS access_server WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login) LOOP @@ -291,6 +291,12 @@ AFTER TRUNCATE ON solana_user_pda_current FOR EACH STATEMENT EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); +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(); + CREATE TABLE IF NOT EXISTS solana_user_pda_history ( id BIGSERIAL PRIMARY KEY, tx_signature TEXT NOT NULL, @@ -410,35 +416,15 @@ CREATE TABLE IF NOT EXISTS user_settings ( 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); - CREATE TABLE IF NOT EXISTS ip_geo_cache ( ip TEXT PRIMARY KEY, geo TEXT, @@ -605,6 +591,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner ON channel_names_state (owner_login, owner_bch_name); +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 TABLE IF NOT EXISTS chat200_state ( owner_login TEXT NOT NULL, owner_bch_name TEXT NOT NULL, @@ -792,21 +804,33 @@ CREATE TABLE IF NOT EXISTS dm_dialog_state ( 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); -CREATE TABLE IF NOT EXISTS dm_sync_peer_state ( - owner_login TEXT NOT NULL, - remote_server_login TEXT NOT NULL, - remote_server_url TEXT NOT NULL, - cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0, - cursor_message_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) +-- Изменяемое состояние доставки исходящей пары. Подписанные блоки остаются +-- неизменяемыми; эта таблица описывает только сетевую доставку пары 1/2 или 3/4. +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_sync_peer_state_owner - ON dm_sync_peer_state(owner_login); +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 message_views_state ( viewer_login TEXT NOT NULL, @@ -876,6 +900,132 @@ BEGIN END; $$; +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 + 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; +$$; + CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() RETURNS TRIGGER LANGUAGE plpgsql @@ -954,6 +1104,8 @@ 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; @@ -966,10 +1118,159 @@ BEGIN 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_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 @@ -1003,10 +1304,161 @@ BEGIN 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_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; @@ -1186,4 +1638,10 @@ AFTER INSERT ON blocks FOR EACH ROW EXECUTE FUNCTION shine_blocks_edit_apply_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(); + COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java index 4877f45f..fcc783dd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java @@ -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 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 getRemoteServerCapabilities() { return remoteServerCapabilities; } + public void setRemoteServerCapabilities(List 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() + '}'; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 3f182a42..4e2cf7c2 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -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; @@ -98,10 +100,7 @@ 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_MarkAllUserSettingsUnsynced_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_UserSettingsSyncBatch_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_SendMessagePair_Handler; @@ -112,10 +111,7 @@ 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_MarkAllUserSettingsUnsynced_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_UserSettingsSyncBatch_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_SendMessagePair_Request; @@ -131,6 +127,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; @@ -139,6 +136,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; @@ -159,6 +157,7 @@ public final class JsonHandlerRegistry { 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()), @@ -215,13 +214,9 @@ public final class JsonHandlerRegistry { 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("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()), - Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()), Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), @@ -229,6 +224,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()), @@ -249,6 +245,7 @@ public final class JsonHandlerRegistry { 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), @@ -305,13 +302,9 @@ public final class JsonHandlerRegistry { 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("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class), - Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class), Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), @@ -319,6 +312,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), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java index dcba0cf4..3d8133e5 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonInboundProcessor.java @@ -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 { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/RemoteUserOperationPolicy.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/RemoteUserOperationPolicy.java new file mode 100644 index 00000000..a4715a48 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/RemoteUserOperationPolicy.java @@ -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 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); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthAccessScopeSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthAccessScopeSupport.java new file mode 100644 index 00000000..3c34f1c2 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthAccessScopeSupport.java @@ -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 + ) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java index 1a3aed35..ccccf696 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java @@ -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]; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index 4ca98505..63e29da6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -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; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ResolveLoginForAuth_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ResolveLoginForAuth_Handler.java new file mode 100644 index 00000000..74291aa6 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ResolveLoginForAuth_Handler.java @@ -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; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index c2dea576..3e817cd8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -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; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index ed6254e8..1c7ed79b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -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 approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); - if (approverConnections.isEmpty()) { - return NetExceptionResponseFactory.error( - req, - 422, - "PAIRING_NO_TRUSTED_SESSION_ONLINE", - "Нет ни одной активной доверенной сессии пользователя в сети" - ); - } EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( canonicalLogin, requesterSessionKey, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index e97fa70f..68a3fed9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -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() {} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java index 78b17822..e96f5b54 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Response.java @@ -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; } } \ No newline at end of file diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Request.java new file mode 100644 index 00000000..7305db6a --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Request.java @@ -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; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Response.java new file mode 100644 index 00000000..57849499 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Response.java @@ -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; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java index 4b4f2526..ca7913b3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Response.java @@ -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; } } \ No newline at end of file diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index c3b063cd..23bc7042 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -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; @@ -65,6 +67,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()); @@ -180,4 +183,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; + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 7d5f0a92..811d4c6c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -32,6 +32,7 @@ public class Net_GetChannelMessages_Response extends Net_Response { private Long metaUpdatedAtMs; private Integer channelTypeCode; private Integer channelTypeVersion; + private Integer subscribersCount; private BlockRef channelRoot; public String getOwnerLogin() { return ownerLogin; } @@ -67,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response { public Integer getChannelTypeVersion() { return channelTypeVersion; } public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; } + public Integer getSubscribersCount() { return subscribersCount; } + public void setSubscribersCount(Integer subscribersCount) { this.subscribersCount = subscribersCount; } + public BlockRef getChannelRoot() { return channelRoot; } public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ServerHello_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ServerHello_Handler.java new file mode 100644 index 00000000..8e9c89d1 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ServerHello_Handler.java @@ -0,0 +1,70 @@ +package server.logic.ws_protocol.JSON.handlers.system; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import utils.config.AppConfig; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +/** + * Временное server-to-server представление без криптографической проверки. + * Заявленный serverLogin принимается на доверии и привязывается к WS-контексту. + */ +public final class Net_ServerHello_Handler implements JsonMessageHandler { + private static final int PROTOCOL_VERSION = 1; + private static final List CAPABILITIES = List.of( + "dm-sync", "settings-sync", "block-sync", "connection-pool"); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_ServerHello_Request req = (Net_ServerHello_Request) baseRequest; + String remoteLogin = normalize(req.getServerLogin()); + int remoteVersion = req.getProtocolVersion() == null ? 0 : req.getProtocolVersion(); + if (ctx == null) { + return NetExceptionResponseFactory.error( + req, WireCodes.Status.BAD_REQUEST, "NO_CONNECTION_CONTEXT", "ServerHello требует WebSocket-контекст"); + } + if (remoteLogin == null || remoteVersion <= 0) { + return NetExceptionResponseFactory.error( + req, WireCodes.Status.BAD_REQUEST, "BAD_SERVER_HELLO", "serverLogin/protocolVersion обязательны"); + } + + LinkedHashSet unique = new LinkedHashSet<>(); + if (req.getCapabilities() != null) { + for (String capability : req.getCapabilities()) { + String normalized = normalize(capability); + if (normalized != null && unique.size() < 64) unique.add(normalized); + } + } + ctx.setServerConnection(true); + ctx.setRemoteServerLogin(remoteLogin); + ctx.setRemoteServerProtocolVersion(remoteVersion); + ctx.setRemoteServerCapabilities(new ArrayList<>(unique)); + + Net_ServerHello_Response resp = new Net_ServerHello_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setAccepted(true); + String localLogin = normalize(AppConfig.getInstance().getParam("server.SHiNE.login")); + resp.setServerLogin(localLogin == null ? "" : localLogin); + resp.setProtocolVersion(PROTOCOL_VERSION); + resp.setCapabilities(CAPABILITIES); + return resp; + } + + private static String normalize(String value) { + if (value == null) return null; + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.isEmpty() ? null : normalized; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Request.java new file mode 100644 index 00000000..e7871bc4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Request.java @@ -0,0 +1,20 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +import java.util.List; + +public final class Net_ServerHello_Request extends Net_Request { + private String serverLogin; + private Integer protocolVersion; + private List capabilities; + + public String getServerLogin() { return serverLogin; } + public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; } + + public Integer getProtocolVersion() { return protocolVersion; } + public void setProtocolVersion(Integer protocolVersion) { this.protocolVersion = protocolVersion; } + + public List getCapabilities() { return capabilities; } + public void setCapabilities(List capabilities) { this.capabilities = capabilities; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Response.java new file mode 100644 index 00000000..0fe9c9a5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.List; + +public final class Net_ServerHello_Response extends Net_Response { + private boolean accepted; + private String serverLogin; + private int protocolVersion; + private List capabilities; + + public boolean isAccepted() { return accepted; } + public void setAccepted(boolean accepted) { this.accepted = accepted; } + + public String getServerLogin() { return serverLogin; } + public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; } + + public int getProtocolVersion() { return protocolVersion; } + public void setProtocolVersion(int protocolVersion) { this.protocolVersion = protocolVersion; } + + public List getCapabilities() { return capabilities; } + public void setCapabilities(List capabilities) { this.capabilities = capabilities; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 961faa65..1298d603 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Re import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.BlockchainStateDAO; +import shine.db.DbController; import shine.db.dao.CurrentUsersDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.util.Arrays; public class Net_GetUser_Handler implements JsonMessageHandler { @@ -63,6 +67,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler { resp.setSolanaKey(u.getSolanaKey()); resp.setBlockchainKey(u.getBlockchainKey()); resp.setClientKey(u.getClientKey()); + loadUserStats(resp, u.getLogin()); // Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна, // автоматически восстанавливаем её для существующего пользователя. @@ -125,4 +130,35 @@ public class Net_GetUser_Handler implements JsonMessageHandler { } return new String(out); } + + private static void loadUserStats(Net_GetUser_Response resp, String login) { + resp.setOwnedPublicChannelsCount(0); + resp.setFollowingUsersCount(0); + resp.setFollowingChannelsCount(0); + resp.setCloseFriendsCount(0); + String sql = """ + SELECT owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count + FROM user_stats_state + WHERE login = ? + LIMIT 1 + """; + try (Connection c = DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return; + } + resp.setOwnedPublicChannelsCount(rs.getInt("owned_public_channels_count")); + resp.setFollowingUsersCount(rs.getInt("following_users_count")); + resp.setFollowingChannelsCount(rs.getInt("following_channels_count")); + resp.setCloseFriendsCount(rs.getInt("close_friends_count")); + } + } catch (Exception e) { + log.warn("GetUser: не удалось загрузить статистику для login={}", login, e); + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java index 0037ed2a..c9c0da10 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java @@ -43,6 +43,10 @@ public class Net_GetUser_Response extends Net_Response { private String serverLastGlobalHash; private Long serverBlockchainSizeBytes; private Long serverBlockchainSizeLimitBytes; + private Integer ownedPublicChannelsCount; + private Integer followingUsersCount; + private Integer followingChannelsCount; + private Integer closeFriendsCount; public Boolean getExists() { return exists; } public void setExists(Boolean exists) { this.exists = exists; } @@ -74,4 +78,16 @@ public class Net_GetUser_Response extends Net_Response { public Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; } public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = serverBlockchainSizeLimitBytes; } + public Integer getOwnedPublicChannelsCount() { return ownedPublicChannelsCount; } + public void setOwnedPublicChannelsCount(Integer ownedPublicChannelsCount) { this.ownedPublicChannelsCount = ownedPublicChannelsCount; } + + public Integer getFollowingUsersCount() { return followingUsersCount; } + public void setFollowingUsersCount(Integer followingUsersCount) { this.followingUsersCount = followingUsersCount; } + + public Integer getFollowingChannelsCount() { return followingChannelsCount; } + public void setFollowingChannelsCount(Integer followingChannelsCount) { this.followingChannelsCount = followingChannelsCount; } + + public Integer getCloseFriendsCount() { return closeFriendsCount; } + public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; } + } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java index ea03979b..168de701 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java @@ -54,7 +54,6 @@ public class Net_GetUserSetting_Handler implements JsonMessageHandler { resp.setValue_num(entry.getValueNum()); resp.setClient_key(entry.getClientKey()); resp.setSignature(entry.getSignature()); - resp.setSynced(entry.isSynced()); return resp; } catch (Exception e) { log.error("GetUserSetting failed", e); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java index 7637612f..6829fb09 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java @@ -49,7 +49,6 @@ public class Net_ListUserSettings_Handler implements JsonMessageHandler { item.setValue_num(e.getValueNum()); item.setClient_key(e.getClientKey()); item.setSignature(e.getSignature()); - item.setSynced(e.isSynced()); items.add(item); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java index 0a9fff3f..375dacfd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java @@ -11,27 +11,21 @@ import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUs import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import server.sync.RemoteUserSettingsSyncClient; import shine.db.DbController; import shine.db.dao.CurrentUsersDAO; -import shine.db.dao.UserAccessServersCurrentDAO; import shine.db.dao.UserSettingsDAO; import shine.db.entities.CurrentUserEntry; -import shine.db.entities.UserAccessServerRouteEntry; import shine.db.entities.UserSettingEntry; import utils.config.ShineSignatureConstants; -import utils.config.AppConfig; import utils.crypto.Ed25519Util; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.SQLException; -import java.util.List; +/** Приём и строгая проверка локальной пользовательской настройки. */ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserSetting_Handler.class); - private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient(); - private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { @@ -54,7 +48,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { long valueNum = req.getValue_num() == null ? 0L : req.getValue_num(); String clientKeyB64 = req.getClient_key().trim(); String signatureB64 = req.getSignature().trim(); - boolean syncDelivery = Boolean.TRUE.equals(req.getSync_delivery()); try { byte[] pubKey32; @@ -63,7 +56,8 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key"); sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", "client_key/signature должны быть Base64"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", "client_key/signature должны быть Base64"); } String signText = ShineSignatureConstants.USER_SETTINGS_PREFIX @@ -74,65 +68,29 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { + escapePart(valueText) + '|' + valueNum; - DbController db = DbController.getInstance(); - CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); - UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - CurrentUserEntry user = usersDAO.getByLogin(c, login); + try (Connection c = DbController.getInstance().getConnection()) { + CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(c, login); if (user == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } String userClientKey = user.getClientKey(); if (userClientKey == null || userClientKey.isBlank()) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "USER_DEVICE_KEY_EMPTY", "У пользователя не задан clientKey в БД"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, + "USER_DEVICE_KEY_EMPTY", "У пользователя не задан clientKey в БД"); } if (!userClientKey.trim().equals(clientKeyB64)) { - return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); + return NetExceptionResponseFactory.error(req, 403, + "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); } - - boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32); - if (!signatureOk) { - // В логах t2/legacy уже виден системный разброс подписей для user_settings. - // Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа. - log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}", - login, settingType, settingKey); + if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "INVALID_SIGNATURE", "Подпись настройки не прошла проверку"); } UserSettingEntry entry = new UserSettingEntry( - login, - settingType, - settingKey, - timeMs, - valueText, - valueNum, - clientKeyB64, - signatureB64, - syncDelivery - ); - int changed = settingsDAO.upsertIfNewer(c, entry); - - if (!syncDelivery && changed > 0) { - int delivered = 0; - String ownServerLogin = String.valueOf(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG) == null ? "" : AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)).trim(); - List routes = UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, login); - for (UserAccessServerRouteEntry route : routes) { - if (route == null) continue; - String remoteLogin = String.valueOf(route.getServerLogin() == null ? "" : route.getServerLogin()).trim(); - String remoteUrl = String.valueOf(route.getServerUrl() == null ? "" : route.getServerUrl()).trim(); - if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue; - if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue; - try { - REMOTE.upsertUserSetting(remoteUrl, entry, true); - delivered++; - } catch (Exception e) { - log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e)); - } - } - if (delivered > 0 || routes.isEmpty()) { - settingsDAO.markSynced(c, login, settingType, settingKey); - } - } + login, settingType, settingKey, timeMs, + valueText, valueNum, clientKeyB64, signatureB64); + UserSettingsDAO.getInstance().upsertIfNewer(c, entry); Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response(); resp.setOp(req.getOp()); @@ -142,15 +100,18 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { resp.setSetting_type(settingType); resp.setSetting_key(settingKey); resp.setTime_ms(timeMs); - resp.setSynced(syncDelivery || changed == 0); return resp; } } catch (SQLException e) { log.error("UpsertUserSetting DB error", e); - return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "DB_ERROR", "Ошибка БД"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, + "DB_ERROR", "Ошибка БД"); } catch (Exception e) { log.error("UpsertUserSetting failed", e); - return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при UpsertUserSetting", e)); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + NetExceptionResponseFactory.detailedMessage( + "Внутренняя ошибка сервера при UpsertUserSetting", e)); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java index aadbbcda..30defaaf 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java @@ -11,7 +11,6 @@ public class Net_GetUserSetting_Response extends Net_Response { private Long value_num; private String client_key; private String signature; - private Boolean synced; public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } @@ -37,6 +36,4 @@ public class Net_GetUserSetting_Response extends Net_Response { public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } - public Boolean getSynced() { return synced; } - public void setSynced(Boolean synced) { this.synced = synced; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java index 55a1fab6..de165de3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java @@ -24,7 +24,6 @@ public class Net_ListUserSettings_Response extends Net_Response { private Long value_num; private String client_key; private String signature; - private Boolean synced; public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } @@ -42,7 +41,5 @@ public class Net_ListUserSettings_Response extends Net_Response { public void setClient_key(String client_key) { this.client_key = client_key; } public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } - public Boolean getSynced() { return synced; } - public void setSynced(Boolean synced) { this.synced = synced; } } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java index 5c551156..151b228e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java @@ -11,7 +11,6 @@ public class Net_UpsertUserSetting_Request extends Net_Request { private Long value_num; private String client_key; private String signature; - private Boolean sync_delivery; public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } @@ -37,6 +36,4 @@ public class Net_UpsertUserSetting_Request extends Net_Request { public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } - public Boolean getSync_delivery() { return sync_delivery; } - public void setSync_delivery(Boolean sync_delivery) { this.sync_delivery = sync_delivery; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java index 2d0ad5cc..f5022f91 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java @@ -7,7 +7,6 @@ public class Net_UpsertUserSetting_Response extends Net_Response { private Integer setting_type; private String setting_key; private Long time_ms; - private Boolean synced; public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } @@ -21,6 +20,4 @@ public class Net_UpsertUserSetting_Response extends Net_Response { public Long getTime_ms() { return time_ms; } public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - public Boolean getSynced() { return synced; } - public void setSynced(Boolean synced) { this.synced = synced; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryIds.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryIds.java new file mode 100644 index 00000000..95e4b7dd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryIds.java @@ -0,0 +1,12 @@ +package server.logic.ws_protocol.JSON.messages; + +import shine.db.entities.SignedMessageEntry; + +public final class DmDeliveryIds { + private DmDeliveryIds() {} + + public static String forEntry(SignedMessageEntry entry) { + if (entry == null) throw new IllegalArgumentException("EMPTY_MESSAGE"); + return entry.getMessageKey() + ":" + entry.getRevisionTimeMs() + ":" + entry.getReencryptedAtMs(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryRealtime.java new file mode 100644 index 00000000..6aee2b19 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryRealtime.java @@ -0,0 +1,25 @@ +package server.logic.ws_protocol.JSON.messages; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import shine.db.entities.DmDeliveryStateEntry; + +public final class DmDeliveryRealtime { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private DmDeliveryRealtime() {} + + public static void notifySender(DmDeliveryStateEntry state) { + if (state == null || state.getFromLogin() == null || state.getFromLogin().isBlank()) return; + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("baseKey", state.getBaseKey()); + payload.put("outgoingKey", state.getOutgoingMessageKey()); + payload.put("deliveryState", state.deliveryStateCode()); + for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(state.getFromLogin())) { + WsEventSender.sendEvent(ctx, "DmDeliveryStateChanged", state.getOutgoingMessageKey(), payload); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java deleted file mode 100644 index 37a1f0e0..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java +++ /dev/null @@ -1,50 +0,0 @@ -package server.logic.ws_protocol.JSON.messages; - -import shine.db.dao.SignedMessagesDAO; -import shine.db.entities.SignedMessageEntry; - -public final class DmSyncApplySupport { - private DmSyncApplySupport() {} - - public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception { - if (ownerLogin == null || ownerLogin.isBlank()) { - throw new IllegalArgumentException("EMPTY_OWNER_LOGIN"); - } - if (blobB64 == null || blobB64.isBlank()) { - throw new IllegalArgumentException("EMPTY_BLOB"); - } - - SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64); - SignedMessagesCore.verifyUsersAndSignature(block); - SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null); - String owner = ownerLogin.trim(); - boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner); - boolean ownerMatchesDeletePair = block.isDeleteType() - && (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner)); - if (!ownerMatchesTarget && !ownerMatchesDeletePair) { - throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH"); - } - - SignedMessagesDAO.ApplyStatus status; - if (block.isContentType()) { - status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry); - } else if (block.isReadReceiptType()) { - status = SignedMessagesDAO.getInstance().insertIfAbsent(entry); - } else if (block.isMessageDeleteType()) { - status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry); - } else if (block.isConversationDeleteType()) { - status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry); - } else { - throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); - } - - return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status); - } - - public record ApplyResult( - String messageKey, - String baseKey, - int messageType, - SignedMessagesDAO.ApplyStatus status - ) {} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java index 66c0b25e..b7208dc0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.DmDeliveryStateDAO; import shine.db.entities.SignedMessageEntry; public class Net_DeleteConversation_Handler implements JsonMessageHandler { @@ -43,6 +44,7 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { + DmDeliveryStateDAO.getInstance().removeMissingMessages(); counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { @@ -63,4 +65,5 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java index fe2e2ae2..0aa317dc 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.DmDeliveryStateDAO; import shine.db.entities.SignedMessageEntry; public class Net_DeleteMessage_Handler implements JsonMessageHandler { @@ -43,6 +44,7 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { + DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey()); counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { @@ -63,4 +65,5 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java index 81192f17..0fe1c88c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java @@ -8,8 +8,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import shine.db.dao.DmSyncOutboxDAO; import shine.db.dao.SignedMessagesDAO; import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.DmSyncOutboxEntry; import shine.db.entities.SignedMessageEntry; import shine.db.entities.UserAccessServerRouteEntry; import utils.config.AppConfig; @@ -18,6 +20,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; +/** Pull синхронизация только событий synced=false с ACK предыдущей страницы. */ public class Net_DmSyncBatch_Handler implements JsonMessageHandler { private static final int DEFAULT_LIMIT = 500; private static final int MAX_LIMIT = 500; @@ -46,13 +49,14 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler { long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs()); String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim(); - SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch( - ownerLogin, - afterStoredAtMs, - afterMessageKey, - limit, - maxBytes - ); + if (req.getAckSyncIds() != null && req.getAckSyncIds().size() > MAX_LIMIT) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "TOO_MANY_ACKS", "ackSyncIds содержит слишком много элементов"); + } + DmSyncOutboxDAO outbox = DmSyncOutboxDAO.getInstance(); + outbox.markSynced(ownerLogin, req.getAckSyncIds()); + List batch = outbox.listUnsynced( + ownerLogin, afterStoredAtMs, afterMessageKey, limit); Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response(); resp.setOp(req.getOp()); @@ -60,28 +64,42 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler { resp.setStatus(WireCodes.Status.OK); resp.setOwnerLogin(ownerLogin); resp.setLimit(limit); - resp.setRawBytes(batch.rawBytes()); - resp.setHasMore(batch.hasMore()); + resp.setRawBytes(0); + resp.setHasMore(false); resp.setNextStoredAtMs(afterStoredAtMs); resp.setNextMessageKey(afterMessageKey); List items = new ArrayList<>(); Base64.Encoder encoder = Base64.getEncoder(); - for (SignedMessageEntry entry : batch.items()) { + int rawBytes = 0; + for (DmSyncOutboxEntry row : batch) { + SignedMessageEntry primary = SignedMessagesDAO.getInstance().getByMessageKey(row.getPrimaryMessageKey()); + if (primary == null || primary.getRawBlock() == null) continue; + SignedMessageEntry secondary = null; + if (row.getSecondaryMessageKey() != null && !row.getSecondaryMessageKey().isBlank()) { + secondary = SignedMessagesDAO.getInstance().getByMessageKey(row.getSecondaryMessageKey()); + if (secondary == null || secondary.getRawBlock() == null) continue; + } + int itemBytes = primary.getRawBlock().length + (secondary == null ? 0 : secondary.getRawBlock().length); + if (!items.isEmpty() && rawBytes + itemBytes > maxBytes) { + resp.setHasMore(true); + break; + } Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item(); - item.setMessageKey(entry.getMessageKey()); - item.setBaseKey(entry.getBaseKey()); - item.setTargetLogin(entry.getTargetLogin()); - item.setFromLogin(entry.getFromLogin()); - item.setToLogin(entry.getToLogin()); - item.setMessageType(entry.getMessageType()); - item.setTimeMs(entry.getTimeMs()); - item.setStoredAtMs(entry.getCreatedAtMs()); - item.setBlobB64(encoder.encodeToString(entry.getRawBlock())); + item.setSyncId(row.getEventId()); + item.setPrimaryMessageKey(row.getPrimaryMessageKey()); + item.setStoredAtMs(row.getCreatedAtMs()); + List blobs = new ArrayList<>(); + if (secondary != null) blobs.add(encoder.encodeToString(secondary.getRawBlock())); + blobs.add(encoder.encodeToString(primary.getRawBlock())); + item.setBlobsB64(blobs); items.add(item); - resp.setNextStoredAtMs(entry.getCreatedAtMs()); - resp.setNextMessageKey(entry.getMessageKey()); + rawBytes += itemBytes; + resp.setNextStoredAtMs(row.getCreatedAtMs()); + resp.setNextMessageKey(row.getPrimaryMessageKey()); } + if (!resp.isHasMore() && batch.size() >= limit) resp.setHasMore(true); + resp.setRawBytes(rawBytes); resp.setItems(items); return resp; } @@ -89,9 +107,7 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler { private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception { for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) { if (route == null || route.getServerLogin() == null) continue; - if (ownServerLogin.equals(normalize(route.getServerLogin()))) { - return true; - } + if (ownServerLogin.equals(normalize(route.getServerLogin()))) return true; } return false; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java index eca54d60..74d4fee1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java @@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.DmDeliveryStateDAO; +import shine.db.entities.DmDeliveryStateEntry; import shine.db.entities.SignedMessageEntry; import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; public class Net_GetDirectMessages_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class); @@ -55,6 +59,11 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler { if (hasMore) { page = new ArrayList<>(page.subList(0, limit)); } + Map deliveryByKey = DmDeliveryStateDAO.getInstance() + .listByOutgoingMessageKeys(page.stream() + .filter(entry -> entry.getMessageType() == SignedMessageBlock.TYPE_OUTGOING_COPY) + .map(SignedMessageEntry::getMessageKey) + .collect(Collectors.toList())); Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response(); resp.setOp(req.getOp()); @@ -80,6 +89,10 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler { item.setCreatedAtMs(entry.getCreatedAtMs()); item.setReadAtMs(entry.getReadAtMs()); item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock())); + DmDeliveryStateEntry delivery = deliveryByKey.get(entry.getMessageKey()); + if (delivery != null) { + item.setDeliveryState(delivery.deliveryStateCode()); + } items.add(item); } resp.setMessages(items); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDmDeliveryStatus_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDmDeliveryStatus_Handler.java new file mode 100644 index 00000000..3a66e347 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDmDeliveryStatus_Handler.java @@ -0,0 +1,35 @@ +package server.logic.ws_protocol.JSON.messages; + +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.messages.entyties.Net_GetDmDeliveryStatus_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDmDeliveryStatus_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.DmDeliveryStateDAO; +import shine.db.entities.DmDeliveryStateEntry; + +/** Read-only проверка: доставил ли peer хотя бы одну копию получателю. */ +public class Net_GetDmDeliveryStatus_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_GetDmDeliveryStatus_Request req = (Net_GetDmDeliveryStatus_Request) baseRequest; + if (req.getMessageKey() == null || req.getMessageKey().isBlank()) { + return NetExceptionResponseFactory.error( + req, WireCodes.Status.BAD_REQUEST, "EMPTY_MESSAGE_KEY", "messageKey обязателен"); + } + String messageKey = req.getMessageKey().trim(); + DmDeliveryStateEntry state = DmDeliveryStateDAO.getInstance().getByOutgoingMessageKey(messageKey); + + Net_GetDmDeliveryStatus_Response resp = new Net_GetDmDeliveryStatus_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(messageKey); + resp.setKnown(state != null); + resp.setDelivered(state != null && state.isDelivered()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java deleted file mode 100644 index f05cc99a..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java +++ /dev/null @@ -1,42 +0,0 @@ -package server.logic.ws_protocol.JSON.messages; - -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.messages.entyties.Net_MarkAllUserSettingsUnsynced_Request; -import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.DbController; -import shine.db.dao.UserSettingsDAO; - -import java.sql.Connection; - -public class Net_MarkAllUserSettingsUnsynced_Handler implements JsonMessageHandler { - private static final Logger log = LoggerFactory.getLogger(Net_MarkAllUserSettingsUnsynced_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_MarkAllUserSettingsUnsynced_Request req = (Net_MarkAllUserSettingsUnsynced_Request) baseRequest; - try (Connection c = DbController.getInstance().getConnection()) { - int updated; - if (req.getLogin() == null || req.getLogin().isBlank()) { - updated = UserSettingsDAO.getInstance().markAllUnsynced(c); - } else { - updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim()); - } - Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setUpdated(updated); - return resp; - } catch (Exception e) { - log.error("MarkAllUserSettingsUnsynced failed", e); - return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); - } - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 811d42ce..053038bc 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -8,9 +8,11 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import server.sync.DmDeliveryCoordinator; import shine.db.dao.SignedMessagesDAO; import shine.db.entities.SignedMessageEntry; +import java.util.Arrays; import java.util.Base64; public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { @@ -40,6 +42,10 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } + if (!DmDeliveryCoordinator.isLocalAccessServer(incoming.toLogin)) { + return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Сервер не обслуживает получателя сообщения"); + } + final SignedMessageEntry entry; try { entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null); @@ -53,16 +59,16 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); - server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers( - incoming.toLogin, - req.getIncomingBlobB64().trim(), - req.getSourceServerLogin() - ); } if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); + return NetExceptionResponseFactory.error(req, 409, "BLOCKED_BY_CONVERSATION_TOMBSTONE", "Переписка уже удалена этой ревизией"); } + SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(entry.getMessageKey()); + if (stored == null || !Arrays.equals(stored.getRawBlock(), entry.getRawBlock())) { + return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения"); + } Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index 332a5555..50146df3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -8,10 +8,14 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import server.sync.DmDeliveryCoordinator; import server.sync.DmFederationService; +import shine.db.dao.DmDeliveryStateDAO; import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.DmDeliveryStateEntry; import shine.db.entities.SignedMessageEntry; +import java.util.Arrays; import java.util.Base64; public class Net_SendMessagePair_Handler implements JsonMessageHandler { @@ -44,10 +48,9 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessageEntry incomingEntry; SignedMessageEntry outgoingEntry; try { - String sourceApi = "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; - incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId); - outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId); + incomingEntry = SignedMessagesCore.toEntry(incoming, "SendMessagePair", originSessionId); + outgoingEntry = SignedMessagesCore.toEntry(outgoing, "SendMessagePair", originSessionId); } catch (IllegalArgumentException ex) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } @@ -79,15 +82,33 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); + return NetExceptionResponseFactory.error(req, 409, "BLOCKED_BY_CONVERSATION_TOMBSTONE", "Переписка уже удалена этой ревизией"); } - if (pairStatus.applied() && ctx != null && ctx.isAuthenticatedUser()) { - DmFederationService.fanOutPair( - incoming.fromLogin, - incoming.toLogin, - req.getIncomingBlobB64().trim(), - req.getOutgoingBlobB64().trim() - ); + SignedMessageEntry storedOutgoing = SignedMessagesDAO.getInstance().getByMessageKey(outgoingEntry.getMessageKey()); + if (storedOutgoing == null || !Arrays.equals(storedOutgoing.getRawBlock(), outgoingEntry.getRawBlock())) { + return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения"); + } + + String eventId = DmDeliveryIds.forEntry(outgoingEntry); + long nowMs = System.currentTimeMillis(); + long acceptedAtMs; + acceptedAtMs = nowMs; + long expiresAtMs = acceptedAtMs + 60L * 60L * 1000L; + int initialState = DmDeliveryStateEntry.ACCEPTED; + DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair( + outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(), + outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(), + acceptedAtMs, expiresAtMs, initialState, + null, null, false + ); + + if (delivery != null && pairStatus.applied()) { + // Первая доставка на единственный access-сервер получателя + // выполняется до ответа клиенту. + DmDeliveryCoordinator.processDueEntry(delivery); + delivery = DmDeliveryStateDAO.getInstance() + .getByOutgoingMessageKey(outgoingEntry.getMessageKey()); } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); @@ -99,6 +120,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { resp.setOutgoingKey(outgoingEntry.getMessageKey()); resp.setDeliveredWsSessions(inCounters.wsDelivered + outCounters.wsDelivered); resp.setDeliveredWebPushSessions(inCounters.pushDelivered + outCounters.pushDelivered); + resp.setDeliveryState(delivery.deliveryStateCode()); return resp; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java deleted file mode 100644 index 2d633fa5..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java +++ /dev/null @@ -1,129 +0,0 @@ -package server.logic.ws_protocol.JSON.messages; - -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.messages.entyties.Net_UserSettingsSyncBatch_Request; -import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.DbController; -import shine.db.dao.UserAccessServersCurrentDAO; -import shine.db.dao.UserSettingsDAO; -import shine.db.entities.UserSettingEntry; -import shine.db.entities.UserAccessServerRouteEntry; -import utils.config.AppConfig; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; - -public class Net_UserSettingsSyncBatch_Handler implements JsonMessageHandler { - private static final Logger log = LoggerFactory.getLogger(Net_UserSettingsSyncBatch_Handler.class); - private static final int DEFAULT_LIMIT = 500; - private static final int MAX_LIMIT = 1000; - private static final int DEFAULT_MAX_BYTES = 3_000_000; - private static final int MAX_BYTES_CAP = 5_000_000; - private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { - Net_UserSettingsSyncBatch_Request req = (Net_UserSettingsSyncBatch_Request) baseRequest; - String ownerLogin = normalizeOriginal(req.getOwnerLogin()); - if (ownerLogin == null) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен"); - } - - String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); - if (ownServerLogin == null) { - return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен"); - } - if (!isLocalAccessServer(ownerLogin, ownServerLogin)) { - return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя"); - } - - int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT); - int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP); - long afterTimeMs = Math.max(0L, req.getAfterTimeMs() == null ? 0L : req.getAfterTimeMs()); - String afterSettingKey = req.getAfterSettingKey() == null ? "" : req.getAfterSettingKey().trim(); - - List batch; - try (Connection c = DbController.getInstance().getConnection()) { - batch = UserSettingsDAO.getInstance().listNewerThan( - c, - ownerLogin, - afterTimeMs, - afterSettingKey, - limit - ); - } - - Net_UserSettingsSyncBatch_Response resp = new Net_UserSettingsSyncBatch_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setOwnerLogin(ownerLogin); - resp.setLimit(limit); - - int rawBytes = 0; - List items = new ArrayList<>(); - for (UserSettingEntry entry : batch) { - Net_UserSettingsSyncBatch_Response.Item item = new Net_UserSettingsSyncBatch_Response.Item(); - item.setLogin(entry.getLogin()); - item.setSetting_type(entry.getSettingType()); - item.setSetting_key(entry.getSettingKey()); - item.setTime_ms(entry.getTimeMs()); - item.setValue_text(entry.getValueText()); - item.setValue_num(entry.getValueNum()); - item.setClient_key(entry.getClientKey()); - item.setSignature(entry.getSignature()); - item.setSynced(entry.isSynced()); - items.add(item); - rawBytes += String.valueOf(entry.getLogin()).length() - + String.valueOf(entry.getSettingKey()).length() - + String.valueOf(entry.getValueText()).length() - + String.valueOf(entry.getClientKey() == null ? "" : entry.getClientKey()).length() - + String.valueOf(entry.getSignature() == null ? "" : entry.getSignature()).length() - + 64; - if (rawBytes > maxBytes) break; - resp.setNextTimeMs(entry.getTimeMs()); - resp.setNextSettingKey(entry.getSettingKey()); - } - resp.setRawBytes(rawBytes); - resp.setHasMore(batch.size() > items.size()); - resp.setItems(items); - return resp; - } - - private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception { - try (Connection c = DbController.getInstance().getConnection()) { - for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, ownerLogin)) { - if (route == null || route.getServerLogin() == null) continue; - if (ownServerLogin.equals(normalize(route.getServerLogin()))) { - return true; - } - } - } - return false; - } - - private static int clamp(int value, int min, int max) { - return Math.max(min, Math.min(max, value)); - } - - private static String normalize(String value) { - if (value == null) return null; - String s = value.trim().toLowerCase(); - return s.isEmpty() ? null : s; - } - - private static String normalizeOriginal(String value) { - if (value == null) return null; - String s = value.trim(); - return s.isEmpty() ? null : s; - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java deleted file mode 100644 index f591524b..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java +++ /dev/null @@ -1,61 +0,0 @@ -package server.logic.ws_protocol.JSON.messages; - -import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response; -import shine.db.dao.UserSettingsDAO; -import shine.db.entities.UserSettingEntry; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.List; - -public final class UserSettingsSyncApplySupport { - private UserSettingsSyncApplySupport() {} - - public static ApplyResult applySyncedItem(Connection c, String ownerLogin, Net_UserSettingsSyncBatch_Response.Item item) throws Exception { - if (item == null) return new ApplyResult(false, "empty_item"); - String login = normalize(item.getLogin()); - String key = normalize(item.getSetting_key()); - if (login == null || key == null) return new ApplyResult(false, "bad_item"); - - UserSettingEntry entry = new UserSettingEntry( - login, - item.getSetting_type() == null ? 0 : item.getSetting_type(), - key, - item.getTime_ms() == null ? 0L : item.getTime_ms(), - item.getValue_text() == null ? "" : item.getValue_text(), - item.getValue_num() == null ? 0L : item.getValue_num(), - item.getClient_key(), - item.getSignature(), - true - ); - int changed = UserSettingsDAO.getInstance().upsertIfNewer(c, entry); - return new ApplyResult(changed > 0, changed > 0 ? "applied" : "ignored"); - } - - public static List toEntries(List items) { - List out = new ArrayList<>(); - if (items == null) return out; - for (Net_UserSettingsSyncBatch_Response.Item item : items) { - if (item == null) continue; - out.add(new UserSettingEntry( - normalize(item.getLogin()), - item.getSetting_type() == null ? 0 : item.getSetting_type(), - normalize(item.getSetting_key()), - item.getTime_ms() == null ? 0L : item.getTime_ms(), - item.getValue_text() == null ? "" : item.getValue_text(), - item.getValue_num() == null ? 0L : item.getValue_num(), - item.getClient_key(), - item.getSignature(), - true - )); - } - return out; - } - - private static String normalize(String value) { - String s = String.valueOf(value == null ? "" : value).trim(); - return s.isEmpty() ? null : s; - } - - public record ApplyResult(boolean applied, String status) {} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java index fdaea682..4801fe80 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java @@ -2,12 +2,16 @@ package server.logic.ws_protocol.JSON.messages.entyties; import server.logic.ws_protocol.JSON.entyties.Net_Request; +import java.util.ArrayList; +import java.util.List; + public class Net_DmSyncBatch_Request extends Net_Request { private String ownerLogin; private Long afterStoredAtMs; private String afterMessageKey; private Integer limit; private Integer maxBytes; + private List ackSyncIds = new ArrayList<>(); public String getOwnerLogin() { return ownerLogin; } public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } @@ -19,4 +23,8 @@ public class Net_DmSyncBatch_Request extends Net_Request { public void setLimit(Integer limit) { this.limit = limit; } public Integer getMaxBytes() { return maxBytes; } public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; } + public List getAckSyncIds() { return ackSyncIds; } + public void setAckSyncIds(List ackSyncIds) { + this.ackSyncIds = ackSyncIds == null ? new ArrayList<>() : ackSyncIds; + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java index edab6afd..6cdb0bc6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java @@ -30,33 +30,20 @@ public class Net_DmSyncBatch_Response extends Net_Response { public void setItems(List items) { this.items = items; } public static class Item { - private String messageKey; - private String baseKey; - private String targetLogin; - private String fromLogin; - private String toLogin; - private int messageType; - private long timeMs; + private String syncId; + private String primaryMessageKey; private long storedAtMs; - private String blobB64; + private List blobsB64 = new ArrayList<>(); - public String getMessageKey() { return messageKey; } - public void setMessageKey(String messageKey) { this.messageKey = messageKey; } - public String getBaseKey() { return baseKey; } - public void setBaseKey(String baseKey) { this.baseKey = baseKey; } - public String getTargetLogin() { return targetLogin; } - public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } - 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 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 String getSyncId() { return syncId; } + public void setSyncId(String syncId) { this.syncId = syncId; } + public String getPrimaryMessageKey() { return primaryMessageKey; } + public void setPrimaryMessageKey(String primaryMessageKey) { this.primaryMessageKey = primaryMessageKey; } public long getStoredAtMs() { return storedAtMs; } public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; } - public String getBlobB64() { return blobB64; } - public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } + public List getBlobsB64() { return blobsB64; } + public void setBlobsB64(List blobsB64) { + this.blobsB64 = blobsB64 == null ? new ArrayList<>() : blobsB64; + } } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java index fdb914cb..a8f4844a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java @@ -42,6 +42,7 @@ public class Net_GetDirectMessages_Response extends Net_Response { private long createdAtMs; private Long readAtMs; private String blobB64; + private String deliveryState; public String getMessageKey() { return messageKey; } public void setMessageKey(String messageKey) { this.messageKey = messageKey; } @@ -67,5 +68,7 @@ public class Net_GetDirectMessages_Response extends Net_Response { public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; } public String getBlobB64() { return blobB64; } public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } + public String getDeliveryState() { return deliveryState; } + public void setDeliveryState(String deliveryState) { this.deliveryState = deliveryState; } } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Request.java new file mode 100644 index 00000000..11359644 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetDmDeliveryStatus_Request extends Net_Request { + private String messageKey; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String value) { this.messageKey = value; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Response.java new file mode 100644 index 00000000..b3443871 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Response.java @@ -0,0 +1,16 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_GetDmDeliveryStatus_Response extends Net_Response { + private String messageKey; + private boolean known; + private boolean delivered; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String value) { this.messageKey = value; } + public boolean isKnown() { return known; } + public void setKnown(boolean value) { this.known = value; } + public boolean isDelivered() { return delivered; } + public void setDelivered(boolean value) { this.delivered = value; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java deleted file mode 100644 index 2e1ca6e7..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java +++ /dev/null @@ -1,10 +0,0 @@ -package server.logic.ws_protocol.JSON.messages.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public class Net_MarkAllUserSettingsUnsynced_Request extends Net_Request { - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java deleted file mode 100644 index 0b5c7f8d..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java +++ /dev/null @@ -1,10 +0,0 @@ -package server.logic.ws_protocol.JSON.messages.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -public class Net_MarkAllUserSettingsUnsynced_Response extends Net_Response { - private Integer updated; - - public Integer getUpdated() { return updated; } - public void setUpdated(Integer updated) { this.updated = updated; } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java index 7dc07361..d2917221 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java @@ -5,12 +5,9 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_SendMessagePair_Request extends Net_Request { private String incomingBlobB64; private String outgoingBlobB64; - private String sourceServerLogin; public String getIncomingBlobB64() { return incomingBlobB64; } public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } public String getOutgoingBlobB64() { return outgoingBlobB64; } public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; } - public String getSourceServerLogin() { return sourceServerLogin; } - public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java index 298e4d3c..8b3cf98e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java @@ -8,6 +8,7 @@ public class Net_SendMessagePair_Response extends Net_Response { private String outgoingKey; private int deliveredWsSessions; private int deliveredWebPushSessions; + private String deliveryState; public String getBaseKey() { return baseKey; } public void setBaseKey(String baseKey) { this.baseKey = baseKey; } @@ -19,4 +20,6 @@ public class Net_SendMessagePair_Response extends Net_Response { public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } + public String getDeliveryState() { return deliveryState; } + public void setDeliveryState(String deliveryState) { this.deliveryState = deliveryState; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java deleted file mode 100644 index c359b46f..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java +++ /dev/null @@ -1,26 +0,0 @@ -package server.logic.ws_protocol.JSON.messages.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public class Net_UserSettingsSyncBatch_Request extends Net_Request { - private String ownerLogin; - private Long afterTimeMs; - private String afterSettingKey; - private Integer limit; - private Integer maxBytes; - - public String getOwnerLogin() { return ownerLogin; } - public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } - - public Long getAfterTimeMs() { return afterTimeMs; } - public void setAfterTimeMs(Long afterTimeMs) { this.afterTimeMs = afterTimeMs; } - - public String getAfterSettingKey() { return afterSettingKey; } - public void setAfterSettingKey(String afterSettingKey) { this.afterSettingKey = afterSettingKey; } - - public Integer getLimit() { return limit; } - public void setLimit(Integer limit) { this.limit = limit; } - - public Integer getMaxBytes() { return maxBytes; } - public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Response.java deleted file mode 100644 index c1a23f58..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Response.java +++ /dev/null @@ -1,62 +0,0 @@ -package server.logic.ws_protocol.JSON.messages.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -public class Net_UserSettingsSyncBatch_Response extends Net_Response { - private String ownerLogin; - private Integer limit; - private Integer rawBytes; - private Boolean hasMore; - private Long nextTimeMs; - private String nextSettingKey; - private List items = new ArrayList<>(); - - public String getOwnerLogin() { return ownerLogin; } - public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } - public Integer getLimit() { return limit; } - public void setLimit(Integer limit) { this.limit = limit; } - public Integer getRawBytes() { return rawBytes; } - public void setRawBytes(Integer rawBytes) { this.rawBytes = rawBytes; } - public Boolean getHasMore() { return hasMore; } - public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; } - public Long getNextTimeMs() { return nextTimeMs; } - public void setNextTimeMs(Long nextTimeMs) { this.nextTimeMs = nextTimeMs; } - public String getNextSettingKey() { return nextSettingKey; } - public void setNextSettingKey(String nextSettingKey) { this.nextSettingKey = nextSettingKey; } - public List getItems() { return items; } - public void setItems(List items) { this.items = items; } - - public static class Item { - private String login; - private Integer setting_type; - private String setting_key; - private Long time_ms; - private String value_text; - private Long value_num; - private String client_key; - private String signature; - private Boolean synced; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - public Integer getSetting_type() { return setting_type; } - public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } - public String getSetting_key() { return setting_key; } - public void setSetting_key(String setting_key) { this.setting_key = setting_key; } - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - public String getValue_text() { return value_text; } - public void setValue_text(String value_text) { this.value_text = value_text; } - public Long getValue_num() { return value_num; } - public void setValue_num(Long value_num) { this.value_num = value_num; } - public String getClient_key() { return client_key; } - public void setClient_key(String client_key) { this.client_key = client_key; } - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } - public Boolean getSynced() { return synced; } - public void setSynced(Boolean synced) { this.synced = synced; } - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java index 2e4741ab..f7d35acc 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -11,27 +11,19 @@ import shine.db.entities.BlockEntry; import shine.db.entities.SyncServerEntry; import utils.blockchain.BlockchainNameUtil; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.nio.ByteBuffer; -import java.time.Duration; import java.util.List; import java.util.Locale; -import java.util.UUID; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; /** - * Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers. + * Фоновая репликация AddBlock через общий постоянный WSS-пул на серверы + * из локальной таблицы sync_servers. */ public final class AddBlockSyncService { private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class); private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final HttpClient HTTP = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .build(); private static final ExecutorService EXECUTOR = new ThreadPoolExecutor( 1, Math.max(2, Runtime.getRuntime().availableProcessors()), @@ -100,14 +92,14 @@ public final class AddBlockSyncService { } private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception { - String wsUrl = buildWsUrl(partner.getServerAddress()); + String wsUrl = ServerConnectionPool.buildWsUrl(partner.getServerAddress()); if (wsUrl == null) { log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}", partner.getLogin(), partner.getServerAddress()); return; } - AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock); + AddBlockPushResult firstTry = pushBlock(partner, blockchainName, currentBlock); if (firstTry.ok()) { log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}", partner.getLogin(), blockchainName, blockNumber); @@ -142,7 +134,7 @@ public final class AddBlockSyncService { } for (BlockEntry blockEntry : missingBlocks) { - AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry); + AddBlockPushResult backfillResult = pushBlock(partner, blockchainName, blockEntry); if (!backfillResult.ok()) { log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}", partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code()); @@ -154,8 +146,8 @@ public final class AddBlockSyncService { partner.getLogin(), blockchainName, fromBlockNumber, blockNumber); } - private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception { - JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry); + private AddBlockPushResult pushBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception { + JsonNode response = sendAddBlock(partner, blockchainName, blockEntry); int status = response.path("status").asInt(500); if (status >= 200 && status < 300) { return AddBlockPushResult.success(); @@ -172,31 +164,16 @@ public final class AddBlockSyncService { return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash); } - private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception { - CompletableFuture responseFuture = new CompletableFuture<>(); - CountDownLatch openLatch = new CountDownLatch(1); - SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); - - WebSocket webSocket = HTTP.newWebSocketBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .buildAsync(URI.create(wsUrl), listener) - .get(8, TimeUnit.SECONDS); - - if (!openLatch.await(8, TimeUnit.SECONDS)) { - tryAbort(webSocket); - throw new TimeoutException("WS open timeout"); - } - - String requestId = "sync-" + UUID.randomUUID(); - String json = buildAddBlockJson(requestId, blockchainName, blockEntry); - webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); - - String responseJson = responseFuture.get(12, TimeUnit.SECONDS); - tryAbort(webSocket); - return MAPPER.readTree(responseJson); + private JsonNode sendAddBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception { + String jsonTemplate = buildAddBlockJsonTemplate(blockchainName, blockEntry); + return ServerConnectionPool.getInstance().request( + partner.getLogin(), + partner.getServerAddress(), + jsonTemplate, + ServerConnectionPool.Priority.BULK); } - private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception { + private String buildAddBlockJsonTemplate(String blockchainName, BlockEntry blockEntry) throws Exception { String prevHashHex = blockEntry.getBlockNumber() <= 0 ? "" : toHex(extractPrevHash32(blockEntry.getBlockBytes())); @@ -205,8 +182,6 @@ public final class AddBlockSyncService { String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex); String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64); - String safeRequestId = MAPPER.writeValueAsString(requestId); - return """ { "op":"AddBlock", @@ -218,7 +193,7 @@ public final class AddBlockSyncService { "blockBytesB64":%s } } - """.formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes); + """.formatted("%s", safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes); } private static byte[] extractPrevHash32(byte[] blockBytes) { @@ -240,32 +215,6 @@ public final class AddBlockSyncService { return s.isEmpty() ? null : s; } - private static String buildWsUrl(String serverAddressRaw) { - String host = normalizeHostLike(serverAddressRaw); - if (host == null) return null; - return "wss://" + host + "/ws"; - } - - private static String normalizeHostLike(String value) { - if (value == null) return null; - String raw = value.trim(); - if (raw.isEmpty()) return null; - try { - String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw; - URI uri = URI.create(withScheme); - String host = uri.getHost(); - if (host == null || host.isBlank()) return null; - return host.trim().toLowerCase(Locale.ROOT); - } catch (Exception e) { - String cleaned = raw - .replaceFirst("^[a-zA-Z]+://", "") - .replaceFirst("/.*$", "") - .trim() - .toLowerCase(Locale.ROOT); - return cleaned.isEmpty() ? null : cleaned; - } - } - private static String toHex(byte[] bytes) { if (bytes == null) return ""; StringBuilder sb = new StringBuilder(bytes.length * 2); @@ -276,17 +225,6 @@ public final class AddBlockSyncService { return sb.toString(); } - private static void tryAbort(WebSocket webSocket) { - try { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); - } catch (Exception ignored) { - } - try { - webSocket.abort(); - } catch (Exception ignored) { - } - } - private record AddBlockPushResult( boolean ok, int status, @@ -309,52 +247,4 @@ public final class AddBlockSyncService { } } - private static final class SyncWsListener implements WebSocket.Listener { - private final CompletableFuture responseFuture; - private final CountDownLatch openLatch; - private final StringBuilder textBuffer = new StringBuilder(); - - private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { - this.responseFuture = responseFuture; - this.openLatch = openLatch; - } - - @Override - public void onOpen(WebSocket webSocket) { - openLatch.countDown(); - webSocket.request(1); - } - - @Override - public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - textBuffer.append(data); - if (last && !responseFuture.isDone()) { - responseFuture.complete(textBuffer.toString()); - } - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { - if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); - } - return CompletableFuture.completedFuture(null); - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(error); - } - openLatch.countDown(); - } - } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java new file mode 100644 index 00000000..02d4aa88 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java @@ -0,0 +1,159 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.messages.DmDeliveryRealtime; +import shine.db.dao.DmDeliveryStateDAO; +import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.DmDeliveryStateEntry; +import shine.db.entities.SignedMessageEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.List; +import java.util.Locale; + +/** + * Координатор доставки исходящей DM-пары на единственный первый access-сервер + * получателя. Первая попытка выполняется до ответа клиенту, последующие — + * пятисекундным воркером. + */ +public final class DmDeliveryCoordinator { + private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class); + private static final long[] ATTEMPT_OFFSETS_MS = { + 0L, + 30_000L, + 5L * 60_000L, + 25L * 60_000L, + 60L * 60_000L + }; + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + private static final RemoteDmDeliveryClient REMOTE = new RemoteDmDeliveryClient(); + private static final DmDeliveryStateDAO DELIVERY_DAO = DmDeliveryStateDAO.getInstance(); + private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance(); + private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); + + private DmDeliveryCoordinator() {} + + public static List listDue(int limit) throws Exception { + return DELIVERY_DAO.listDue(System.currentTimeMillis(), Math.max(1, limit)); + } + + public static void processDueEntry(DmDeliveryStateEntry snapshot) { + if (snapshot == null) return; + try { + long now = System.currentTimeMillis(); + Long nextAttemptAtMs = nextAttemptAt(snapshot.getCreatedAtMs(), snapshot.getAttemptIndex() + 1); + if (!DELIVERY_DAO.claimAttempt(snapshot, now, nextAttemptAtMs)) return; + + DmDeliveryStateEntry current = DELIVERY_DAO.getByEventId(snapshot.getEventId()); + if (current == null || current.isDelivered() + || current.getDeliveryState() == DmDeliveryStateEntry.FAILED) return; + + boolean finalAttempt = snapshot.getAttemptIndex() >= ATTEMPT_OFFSETS_MS.length - 1 + || now >= current.getDeliveryExpiresAtMs(); + DmDeliveryStateEntry afterAttempt = attemptPrimaryRecipientRoute( + current, finalAttempt ? null : nextAttemptAtMs); + + if (finalAttempt && afterAttempt != null && !afterAttempt.isDelivered()) { + afterAttempt = DELIVERY_DAO.finishAtExpiry(afterAttempt.getEventId(), System.currentTimeMillis()); + } + DmDeliveryRealtime.notifySender(afterAttempt); + } catch (Exception e) { + log.warn("DM delivery attempt failed: eventId={}", snapshot.getEventId(), e); + } + } + + private static DmDeliveryStateEntry attemptPrimaryRecipientRoute( + DmDeliveryStateEntry current, + Long nextAttemptAtMs + ) throws Exception { + if (current == null) return null; + UserAccessServerRouteEntry route = primaryRoute(current.getToLogin()); + String routesHash = routesHash(route); + if (route == null) { + return DELIVERY_DAO.updateAfterAttempt( + current.getEventId(), DmDeliveryStateEntry.ACCEPTED, null, + routesHash, nextAttemptAtMs, "RECIPIENT_ACCESS_SERVER_NOT_FOUND", System.currentTimeMillis()); + } + + SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey()); + if (incoming == null || incoming.getRawBlock() == null) { + return DELIVERY_DAO.updateAfterAttempt( + current.getEventId(), current.getDeliveryState(), current.getDeliveredServerLogin(), + routesHash, nextAttemptAtMs, "INCOMING_BLOB_NOT_FOUND", System.currentTimeMillis()); + } + + String routeLogin = normalize(route.getServerLogin()); + try { + if (!routeLogin.equals(ownServerLogin())) { + REMOTE.receiveIncomingMessage( + routeLogin, + route.getServerUrl(), + Base64.getEncoder().encodeToString(incoming.getRawBlock()), + ownServerLogin()); + } + return DELIVERY_DAO.updateAfterAttempt( + current.getEventId(), DmDeliveryStateEntry.DELIVERED, routeLogin, + routesHash, nextAttemptAtMs, null, System.currentTimeMillis()); + } catch (Exception e) { + log.info("DM recipient server unavailable: messageKey={} server={}", + current.getOutgoingMessageKey(), routeLogin); + return DELIVERY_DAO.updateAfterAttempt( + current.getEventId(), DmDeliveryStateEntry.ACCEPTED, null, + routesHash, nextAttemptAtMs, compactError(e), System.currentTimeMillis()); + } + } + + public static boolean isLocalAccessServer(String ownerLogin) throws Exception { + UserAccessServerRouteEntry route = primaryRoute(ownerLogin); + String own = ownServerLogin(); + return route != null && own != null && own.equals(normalize(route.getServerLogin())); + } + + public static String currentServerLogin() { + return ownServerLogin(); + } + + private static UserAccessServerRouteEntry primaryRoute(String ownerLogin) throws Exception { + for (UserAccessServerRouteEntry route : ACCESS_DAO.listByUserLogin(ownerLogin)) { + if (route == null || route.getServerUrl() == null || route.getServerUrl().isBlank()) continue; + if (normalize(route.getServerLogin()) != null) return route; + } + return null; + } + + private static String routesHash(UserAccessServerRouteEntry route) throws Exception { + String normalized = route == null ? null : normalize(route.getServerLogin()); + String login = normalized == null ? "" : normalized; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(login.getBytes(StandardCharsets.UTF_8)); + StringBuilder out = new StringBuilder(hash.length * 2); + for (byte b : hash) out.append(String.format("%02x", b & 0xff)); + return out.toString(); + } + + private static Long nextAttemptAt(long createdAtMs, int nextAttemptIndex) { + if (nextAttemptIndex < 0 || nextAttemptIndex >= ATTEMPT_OFFSETS_MS.length) return null; + return createdAtMs + ATTEMPT_OFFSETS_MS[nextAttemptIndex]; + } + + private static String ownServerLogin() { + 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; + } + + private static String compactError(Exception e) { + String text = String.valueOf(e == null ? "unknown" : e.getMessage()); + return text.length() <= 500 ? text : text.substring(0, 500); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java index 47994619..5467a92b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java @@ -8,59 +8,17 @@ import utils.config.AppConfig; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +/** Доставка DM-tombstone на единственные access-серверы обеих сторон. */ public final class DmFederationService { private static final Logger log = LoggerFactory.getLogger(DmFederationService.class); private static final String CONFIG_KEY = "server.SHiNE.login"; - private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + private static final RemoteDmDeliveryClient REMOTE = new RemoteDmDeliveryClient(); private DmFederationService() {} - public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) { - try { - Map senderRoutes = - routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin)); - Map recipientRoutes = - routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)); - - String ownServerLogin = ownServerLogin(); - for (UserAccessServerRouteEntry route : senderRoutes.values()) { - if (isOwnServer(route, ownServerLogin)) continue; - REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin); - } - for (UserAccessServerRouteEntry route : recipientRoutes.values()) { - if (isOwnServer(route, ownServerLogin)) continue; - if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue; - REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); - } - } catch (Exception e) { - log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); - } - } - - public static void fanOutIncomingToRecipientAccessServers( - String toLogin, - String incomingBlobB64, - String sourceServerLogin - ) { - try { - Map recipientRoutes = - routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)); - String ownServerLogin = ownServerLogin(); - String normalizedSource = normalize(sourceServerLogin); - for (UserAccessServerRouteEntry route : recipientRoutes.values()) { - String routeLogin = normalize(route.getServerLogin()); - if (routeLogin == null) continue; - if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue; - if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue; - REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); - } - } catch (Exception e) { - log.warn("DM federation incoming relay failed: to={}", toLogin, e); - } - } - public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) { fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true); } @@ -69,49 +27,50 @@ public final class DmFederationService { fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, false); } - private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) { + private static void fanOutSingleDelete( + String fromLogin, + String toLogin, + int messageType, + String blobB64, + boolean oneMessageDelete + ) { try { - Map routes = routesByLogin( - UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin), - UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin) - ); + Map routes = new LinkedHashMap<>(); + putPrimaryRoute(routes, fromLogin); + putPrimaryRoute(routes, toLogin); String ownServerLogin = ownServerLogin(); for (UserAccessServerRouteEntry route : routes.values()) { if (isOwnServer(route, ownServerLogin)) continue; if (oneMessageDelete) { - REMOTE.deleteMessage(route.getServerUrl(), blobB64); + REMOTE.deleteMessage(route.getServerLogin(), route.getServerUrl(), blobB64); } else { - REMOTE.deleteConversation(route.getServerUrl(), blobB64); + REMOTE.deleteConversation(route.getServerLogin(), route.getServerUrl(), blobB64); } } } catch (Exception e) { - log.warn("DM federation delete fan-out failed: from={} to={} type={}", + log.warn("DM delete delivery failed: from={} to={} type={}", fromLogin, toLogin, messageType, e); } } - @SafeVarargs - private static Map routesByLogin( - List... routeLists - ) { - Map out = new LinkedHashMap<>(); - for (List routeList : routeLists) { - for (UserAccessServerRouteEntry route : routeList) { - if (route == null) continue; - String login = normalize(route.getServerLogin()); - String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim(); - if (login == null || address.isBlank()) continue; - out.putIfAbsent(login, route); - } + private static void putPrimaryRoute(Map out, String ownerLogin) + throws Exception { + List routes = + UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin); + for (UserAccessServerRouteEntry route : routes) { + if (route == null) continue; + String login = normalize(route.getServerLogin()); + String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim(); + if (login == null || address.isBlank()) continue; + out.putIfAbsent(login, route); + return; } - return out; } private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) { return ownServerLogin != null && route != null - && route.getServerLogin() != null - && ownServerLogin.equalsIgnoreCase(route.getServerLogin()); + && ownServerLogin.equals(normalize(route.getServerLogin())); } private static String ownServerLogin() { @@ -120,7 +79,7 @@ public final class DmFederationService { private static String normalize(String value) { if (value == null) return null; - String s = value.trim().toLowerCase(); - return s.isEmpty() ? null : s; + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.isEmpty() ? null : normalized; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmSyncWakeSignal.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmSyncWakeSignal.java new file mode 100644 index 00000000..8e185550 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmSyncWakeSignal.java @@ -0,0 +1,17 @@ +package server.sync; + +import java.util.concurrent.atomic.AtomicBoolean; + +public final class DmSyncWakeSignal { + private static final AtomicBoolean REQUESTED = new AtomicBoolean(false); + + private DmSyncWakeSignal() {} + + public static void request() { + REQUESTED.set(true); + } + + public static boolean consume() { + return REQUESTED.getAndSet(false); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java index 1ac52c97..7d4e4e87 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java @@ -2,37 +2,22 @@ package server.sync; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.nio.ByteBuffer; -import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Locale; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; /** * Минимальный клиент для межсерверных JSON-op запросов по WSS. */ public final class RemoteBlockchainSyncClient { - private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class); private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final HttpClient HTTP = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .build(); public List listBlockchainHeads(String serverAddressRaw) throws Exception { - JsonNode response = send(serverAddressRaw, """ + return listBlockchainHeads(null, serverAddressRaw); + } + + public List listBlockchainHeads(String serverLogin, String serverAddressRaw) throws Exception { + JsonNode response = send(serverLogin, serverAddressRaw, """ { "op":"ListBlockchainHeads", "requestId":%s, @@ -62,8 +47,12 @@ public final class RemoteBlockchainSyncClient { } public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception { + return getSyncUserProfile(null, serverAddressRaw, login); + } + + public RemoteSyncUserProfile getSyncUserProfile(String serverLogin, String serverAddressRaw, String login) throws Exception { String safeLogin = MAPPER.writeValueAsString(login); - JsonNode response = send(serverAddressRaw, """ + JsonNode response = send(serverLogin, serverAddressRaw, """ { "op":"GetSyncUserProfile", "requestId":%s, @@ -96,9 +85,17 @@ public final class RemoteBlockchainSyncClient { ); } - public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception { + public RemoteBlockchainBlock getBlockchainBlock( + String serverAddressRaw, String blockchainName, int blockNumber + ) throws Exception { + return getBlockchainBlock(null, serverAddressRaw, blockchainName, blockNumber); + } + + public RemoteBlockchainBlock getBlockchainBlock( + String serverLogin, String serverAddressRaw, String blockchainName, int blockNumber + ) throws Exception { String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); - JsonNode response = send(serverAddressRaw, """ + JsonNode response = send(serverLogin, serverAddressRaw, """ { "op":"GetBlockchainBlock", "requestId":%s, @@ -126,32 +123,12 @@ public final class RemoteBlockchainSyncClient { ); } - private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { - String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID()); - String json = jsonTemplate.formatted(requestId); - String wsUrl = buildWsUrl(serverAddressRaw); - if (wsUrl == null) { - throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); - } - - CompletableFuture responseFuture = new CompletableFuture<>(); - CountDownLatch openLatch = new CountDownLatch(1); - SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); - - WebSocket webSocket = HTTP.newWebSocketBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .buildAsync(URI.create(wsUrl), listener) - .get(8, TimeUnit.SECONDS); - - if (!openLatch.await(8, TimeUnit.SECONDS)) { - tryAbort(webSocket); - throw new TimeoutException("WS open timeout"); - } - - webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); - String responseJson = responseFuture.get(12, TimeUnit.SECONDS); - tryAbort(webSocket); - return MAPPER.readTree(responseJson); + private JsonNode send(String serverLogin, String serverAddressRaw, String jsonTemplate) throws Exception { + return ServerConnectionPool.getInstance().request( + serverLogin, + serverAddressRaw, + jsonTemplate, + ServerConnectionPool.Priority.BULK); } private static String errorCode(JsonNode response) { @@ -161,40 +138,7 @@ public final class RemoteBlockchainSyncClient { } static String buildWsUrl(String serverAddressRaw) { - String host = normalizeHostLike(serverAddressRaw); - if (host == null) return null; - return "wss://" + host + "/ws"; - } - - private static String normalizeHostLike(String value) { - if (value == null) return null; - String raw = value.trim(); - if (raw.isEmpty()) return null; - try { - String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw; - URI uri = URI.create(withScheme); - String host = uri.getHost(); - if (host == null || host.isBlank()) return null; - return host.trim().toLowerCase(Locale.ROOT); - } catch (Exception e) { - String cleaned = raw - .replaceFirst("^[a-zA-Z]+://", "") - .replaceFirst("/.*$", "") - .trim() - .toLowerCase(Locale.ROOT); - return cleaned.isEmpty() ? null : cleaned; - } - } - - private static void tryAbort(WebSocket webSocket) { - try { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); - } catch (Exception ignored) { - } - try { - webSocket.abort(); - } catch (Exception ignored) { - } + return ServerConnectionPool.buildWsUrl(serverAddressRaw); } public record RemoteBlockchainHead( @@ -220,53 +164,4 @@ public final class RemoteBlockchainSyncClient { long blockchainSizeLimitBytes ) {} - private static final class SyncWsListener implements WebSocket.Listener { - private final CompletableFuture responseFuture; - private final CountDownLatch openLatch; - private final StringBuilder textBuffer = new StringBuilder(); - - private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { - this.responseFuture = responseFuture; - this.openLatch = openLatch; - } - - @Override - public void onOpen(WebSocket webSocket) { - openLatch.countDown(); - webSocket.request(1); - } - - @Override - public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - textBuffer.append(data); - if (last && !responseFuture.isDone()) { - responseFuture.complete(textBuffer.toString()); - } - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { - if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); - } - return CompletableFuture.completedFuture(null); - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - log.warn("Remote sync websocket error: {}", String.valueOf(error)); - if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(error); - } - openLatch.countDown(); - } - } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmDeliveryClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmDeliveryClient.java new file mode 100644 index 00000000..313d6202 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmDeliveryClient.java @@ -0,0 +1,74 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Клиент межсерверной доставки DM на единственный access-сервер получателя. */ +public final class RemoteDmDeliveryClient { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public void receiveIncomingMessage( + String targetServerLogin, + String serverAddressRaw, + String incomingBlobB64, + String sourceServerLogin + ) throws Exception { + String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); + JsonNode response = send(targetServerLogin, serverAddressRaw, """ + { + "op":"ReceiveIncomingMessage", + "requestId":%s, + "payload":{ + "incomingBlobB64":%s%s + } + } + """.formatted("%s", incomingJson, sourceServerLoginJson)); + ensureOk("ReceiveIncomingMessage", response); + } + + public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception { + String blobJson = MAPPER.writeValueAsString(blobB64); + JsonNode response = send(targetServerLogin, serverAddressRaw, """ + { + "op":"DeleteMessage", + "requestId":%s, + "payload":{"blobB64":%s} + } + """.formatted("%s", blobJson)); + ensureOk("DeleteMessage", response); + } + + public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception { + String blobJson = MAPPER.writeValueAsString(blobB64); + JsonNode response = send(targetServerLogin, serverAddressRaw, """ + { + "op":"DeleteConversation", + "requestId":%s, + "payload":{"blobB64":%s} + } + """.formatted("%s", blobJson)); + ensureOk("DeleteConversation", response); + } + + private JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception { + return ServerConnectionPool.getInstance().request( + targetServerLogin, + serverAddressRaw, + jsonTemplate, + ServerConnectionPool.Priority.REALTIME); + } + + private String toOptionalJsonField(String fieldName, String value) throws Exception { + if (value == null || value.isBlank()) return ""; + return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim()); + } + + private void ensureOk(String op, JsonNode response) { + int status = response.path("status").asInt(500); + if (status >= 200 && status < 300) return; + String code = response.path("code").asText(""); + if (code.isBlank()) code = response.path("error").asText(""); + throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java index cdd7c1df..16b1280f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java @@ -3,31 +3,33 @@ package server.sync; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.nio.ByteBuffer; -import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +/** Клиент стабильных межсерверных DM-операций. */ public final class RemoteDmSyncClient { private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final HttpClient HTTP = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .build(); - public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception { + public void sendMessagePair( + String serverAddressRaw, + String incomingBlobB64, + String outgoingBlobB64, + String sourceServerLogin + ) throws Exception { + sendMessagePair(null, serverAddressRaw, incomingBlobB64, outgoingBlobB64, sourceServerLogin); + } + + public void sendMessagePair( + String targetServerLogin, + String serverAddressRaw, + String incomingBlobB64, + String outgoingBlobB64, + String sourceServerLogin + ) throws Exception { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); - JsonNode response = send(serverAddressRaw, """ + JsonNode response = send(targetServerLogin, serverAddressRaw, """ { "op":"ReceiveOutcomingMessage", "requestId":%s, @@ -40,10 +42,23 @@ public final class RemoteDmSyncClient { ensureOk("ReceiveOutcomingMessage", response); } - public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception { + public void receiveIncomingMessage( + String serverAddressRaw, + String incomingBlobB64, + String sourceServerLogin + ) throws Exception { + receiveIncomingMessage(null, serverAddressRaw, incomingBlobB64, sourceServerLogin); + } + + public void receiveIncomingMessage( + String targetServerLogin, + String serverAddressRaw, + String incomingBlobB64, + String sourceServerLogin + ) throws Exception { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); - JsonNode response = send(serverAddressRaw, """ + JsonNode response = send(targetServerLogin, serverAddressRaw, """ { "op":"ReceiveIncomingMessage", "requestId":%s, @@ -55,17 +70,74 @@ public final class RemoteDmSyncClient { ensureOk("ReceiveIncomingMessage", response); } + public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception { + return getDmDeliveryStatus(null, serverAddressRaw, messageKey); + } + + public RemoteDeliveryStatus getDmDeliveryStatus( + String targetServerLogin, String serverAddressRaw, String messageKey + ) throws Exception { + String messageKeyJson = MAPPER.writeValueAsString(messageKey); + JsonNode response = send(targetServerLogin, serverAddressRaw, """ + { + "op":"GetDmDeliveryStatus", + "requestId":%s, + "payload":{ + "messageKey":%s + } + } + """.formatted("%s", messageKeyJson)); + ensureOk("GetDmDeliveryStatus", response); + JsonNode payload = response.path("payload"); + return new RemoteDeliveryStatus( + payload.path("messageKey").asText(messageKey), + payload.path("known").asBoolean(false), + payload.path("delivered").asBoolean(false) + ); + } + public RemoteDmBatch dmSyncBatch( String serverAddressRaw, String ownerLogin, long afterStoredAtMs, String afterMessageKey, int limit, - int maxBytes + int maxBytes, + List ackSyncIds + ) throws Exception { + return dmSyncBatch(null, serverAddressRaw, ownerLogin, afterStoredAtMs, + afterMessageKey, limit, maxBytes, ackSyncIds); + } + + public RemoteDmBatch dmSyncBatch( + String targetServerLogin, + String serverAddressRaw, + String ownerLogin, + long afterStoredAtMs, + String afterMessageKey, + int limit, + int maxBytes, + List ackSyncIds + ) throws Exception { + try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) { + return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey, + limit, maxBytes, ackSyncIds); + } + } + + public RemoteDmBatch dmSyncBatch( + RemoteSyncSession session, + String ownerLogin, + long afterStoredAtMs, + String afterMessageKey, + int limit, + int maxBytes, + List ackSyncIds ) throws Exception { String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin); String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey); - JsonNode response = send(serverAddressRaw, """ + String ackSyncIdsJson = MAPPER.writeValueAsString(ackSyncIds == null ? List.of() : ackSyncIds); + JsonNode response = session.send(""" { "op":"DmSyncBatch", "requestId":%s, @@ -74,10 +146,12 @@ public final class RemoteDmSyncClient { "afterStoredAtMs":%d, "afterMessageKey":%s, "limit":%d, - "maxBytes":%d + "maxBytes":%d, + "ackSyncIds":%s } } - """.formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes)); + """.formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, + limit, maxBytes, ackSyncIdsJson)); ensureOk("DmSyncBatch", response); JsonNode payload = response.path("payload"); @@ -85,11 +159,17 @@ public final class RemoteDmSyncClient { JsonNode arr = payload.path("items"); if (arr.isArray()) { for (JsonNode item : arr) { + List blobs = new ArrayList<>(); + JsonNode blobsNode = item.path("blobsB64"); + if (blobsNode.isArray()) for (JsonNode blob : blobsNode) blobs.add(blob.asText("")); + if (blobs.isEmpty() && !item.path("blobB64").asText("").isBlank()) { + blobs.add(item.path("blobB64").asText("")); + } items.add(new RemoteDmItem( - item.path("messageKey").asText(""), + item.path("syncId").asText(""), + item.path("primaryMessageKey").asText(item.path("messageKey").asText("")), item.path("storedAtMs").asLong(0L), - item.path("blobB64").asText("") - )); + blobs)); } } return new RemoteDmBatch( @@ -101,65 +181,47 @@ public final class RemoteDmSyncClient { } public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { + deleteMessage(null, serverAddressRaw, blobB64); + } + + public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception { String blobJson = MAPPER.writeValueAsString(blobB64); - JsonNode response = send(serverAddressRaw, """ + JsonNode response = send(targetServerLogin, serverAddressRaw, """ { "op":"DeleteMessage", "requestId":%s, - "payload":{ - "blobB64":%s - } + "payload":{"blobB64":%s} } """.formatted("%s", blobJson)); ensureOk("DeleteMessage", response); } public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception { + deleteConversation(null, serverAddressRaw, blobB64); + } + + public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception { String blobJson = MAPPER.writeValueAsString(blobB64); - JsonNode response = send(serverAddressRaw, """ + JsonNode response = send(targetServerLogin, serverAddressRaw, """ { "op":"DeleteConversation", "requestId":%s, - "payload":{ - "blobB64":%s - } + "payload":{"blobB64":%s} } """.formatted("%s", blobJson)); ensureOk("DeleteConversation", response); } - private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { - String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID()); - String json = jsonTemplate.formatted(requestId); - String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); - if (wsUrl == null) { - throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); - } - - CompletableFuture responseFuture = new CompletableFuture<>(); - CountDownLatch openLatch = new CountDownLatch(1); - SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); - - WebSocket webSocket = HTTP.newWebSocketBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .buildAsync(URI.create(wsUrl), listener) - .get(8, TimeUnit.SECONDS); - - if (!openLatch.await(8, TimeUnit.SECONDS)) { - tryAbort(webSocket); - throw new TimeoutException("WS open timeout"); - } - - webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); - String responseJson = responseFuture.get(12, TimeUnit.SECONDS); - tryAbort(webSocket); - return MAPPER.readTree(responseJson); + private JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception { + return ServerConnectionPool.getInstance().request( + targetServerLogin, + serverAddressRaw, + jsonTemplate, + ServerConnectionPool.Priority.REALTIME); } private String toOptionalJsonField(String fieldName, String value) throws Exception { - if (value == null || value.isBlank()) { - return ""; - } + if (value == null || value.isBlank()) return ""; return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim()); } @@ -171,76 +233,10 @@ public final class RemoteDmSyncClient { throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); } - private static void tryAbort(WebSocket webSocket) { - try { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); - } catch (Exception ignored) { - } - try { - webSocket.abort(); - } catch (Exception ignored) { - } - } - - public record RemoteDmBatch( - long nextStoredAtMs, - String nextMessageKey, - boolean hasMore, - List items - ) {} - + public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {} + public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List items) {} public record RemoteDmItem( - String messageKey, - long storedAtMs, - String blobB64 + String syncId, String primaryMessageKey, long storedAtMs, List blobsB64 ) {} - private static final class SyncWsListener implements WebSocket.Listener { - private final CompletableFuture responseFuture; - private final CountDownLatch openLatch; - private final StringBuilder textBuffer = new StringBuilder(); - - private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { - this.responseFuture = responseFuture; - this.openLatch = openLatch; - } - - @Override - public void onOpen(WebSocket webSocket) { - openLatch.countDown(); - webSocket.request(1); - } - - @Override - public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - textBuffer.append(data); - if (last && !responseFuture.isDone()) { - responseFuture.complete(textBuffer.toString()); - } - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { - if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); - } - return CompletableFuture.completedFuture(null); - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(error); - } - openLatch.countDown(); - } - } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteSyncSession.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteSyncSession.java new file mode 100644 index 00000000..3f5bca1f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteSyncSession.java @@ -0,0 +1,35 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Логический последовательный сеанс поверх общего постоянного WSS-пула. + * close() больше не закрывает физическое соединение с сервером. + */ +public final class RemoteSyncSession implements AutoCloseable { + private final String serverLogin; + private final String serverAddress; + + /** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */ + public RemoteSyncSession(String serverAddressRaw) throws Exception { + this(null, serverAddressRaw); + } + + public RemoteSyncSession(String serverLogin, String serverAddressRaw) { + this.serverLogin = serverLogin; + this.serverAddress = serverAddressRaw; + } + + public synchronized JsonNode send(String jsonTemplate) throws Exception { + return ServerConnectionPool.getInstance().request( + serverLogin, + serverAddress, + jsonTemplate, + ServerConnectionPool.Priority.NORMAL); + } + + @Override + public void close() { + // Физическое соединение принадлежит ServerConnectionPool. + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java index f2e545b2..08654cf9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java @@ -4,27 +4,28 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import shine.db.entities.UserSettingEntry; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; public final class RemoteUserSettingsSyncClient { private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final HttpClient HTTP = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .build(); - public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception { - JsonNode response = send(serverAddressRaw, """ + public void upsertUserSetting( + String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery + ) throws Exception { + upsertUserSetting(null, serverAddressRaw, entry, syncDelivery); + } + + public void upsertUserSetting( + String targetServerLogin, String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery + ) throws Exception { + try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) { + upsertUserSetting(session, entry, syncDelivery); + } + } + + public void upsertUserSetting(RemoteSyncSession session, UserSettingEntry entry, boolean syncDelivery) throws Exception { + JsonNode response = session.send(""" { "op":"UpsertUserSetting", "requestId":%s, @@ -63,7 +64,33 @@ public final class RemoteUserSettingsSyncClient { int limit, int maxBytes ) throws Exception { - JsonNode response = send(serverAddressRaw, """ + return userSettingsSyncBatch(null, serverAddressRaw, ownerLogin, + afterTimeMs, afterSettingKey, limit, maxBytes); + } + + public RemoteUserSettingsBatch userSettingsSyncBatch( + String targetServerLogin, + String serverAddressRaw, + String ownerLogin, + long afterTimeMs, + String afterSettingKey, + int limit, + int maxBytes + ) throws Exception { + try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) { + return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes); + } + } + + public RemoteUserSettingsBatch userSettingsSyncBatch( + RemoteSyncSession session, + String ownerLogin, + long afterTimeMs, + String afterSettingKey, + int limit, + int maxBytes + ) throws Exception { + JsonNode response = session.send(""" { "op":"UserSettingsSyncBatch", "requestId":%s, @@ -110,34 +137,6 @@ public final class RemoteUserSettingsSyncClient { ); } - private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { - String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID()); - String json = jsonTemplate.formatted(requestId); - String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); - if (wsUrl == null) { - throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); - } - - CompletableFuture responseFuture = new CompletableFuture<>(); - CountDownLatch openLatch = new CountDownLatch(1); - SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); - - WebSocket webSocket = HTTP.newWebSocketBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .buildAsync(URI.create(wsUrl), listener) - .get(8, TimeUnit.SECONDS); - - if (!openLatch.await(8, TimeUnit.SECONDS)) { - tryAbort(webSocket); - throw new TimeoutException("WS open timeout"); - } - - webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); - String responseJson = responseFuture.get(12, TimeUnit.SECONDS); - tryAbort(webSocket); - return MAPPER.readTree(responseJson); - } - private void ensureOk(String op, JsonNode response) { int status = response.path("status").asInt(500); if (status >= 200 && status < 300) return; @@ -146,17 +145,6 @@ public final class RemoteUserSettingsSyncClient { throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); } - private static void tryAbort(WebSocket webSocket) { - try { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); - } catch (Exception ignored) { - } - try { - webSocket.abort(); - } catch (Exception ignored) { - } - } - public record RemoteUserSettingsBatch( long nextTimeMs, String nextSettingKey, @@ -175,35 +163,4 @@ public final class RemoteUserSettingsSyncClient { String signature ) {} - private static final class SyncWsListener implements WebSocket.Listener { - private final CompletableFuture responseFuture; - private final CountDownLatch openLatch; - private final StringBuilder textBuffer = new StringBuilder(); - - private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { - this.responseFuture = responseFuture; - this.openLatch = openLatch; - } - - @Override - public void onOpen(WebSocket webSocket) { - openLatch.countDown(); - webSocket.request(1); - } - - @Override - public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - textBuffer.append(data); - if (last && !responseFuture.isDone()) { - responseFuture.complete(textBuffer.toString()); - } - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - responseFuture.completeExceptionally(error); - } - } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/ServerConnectionPool.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/ServerConnectionPool.java new file mode 100644 index 00000000..d53a8c89 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/ServerConnectionPool.java @@ -0,0 +1,763 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.dao.SyncServersDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.SyncServerEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Общий пул постоянных WSS-соединений между физическими серверами SHiNE. + * + *

Пул меняет только транспорт: существующие JSON-операции, ACK, outbox и + * расписания повторов остаются обязанностью вызывающих сервисов.

+ */ +public final class ServerConnectionPool implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(ServerConnectionPool.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + private static final int REQUEST_WORKERS = Math.max( + 8, Math.min(32, Runtime.getRuntime().availableProcessors() * 2)); + private static final int CONNECTION_WORKERS = Math.max( + 4, Math.min(16, Runtime.getRuntime().availableProcessors())); + private static final ServerConnectionPool INSTANCE = new ServerConnectionPool(); + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + private final ConcurrentHashMap peers = new ConcurrentHashMap<>(); + private final AtomicBoolean started = new AtomicBoolean(false); + private final AtomicBoolean closed = new AtomicBoolean(false); + private final AtomicLong requestSequence = new AtomicLong(); + + private final ThreadPoolExecutor requestExecutor = new ThreadPoolExecutor( + REQUEST_WORKERS, + REQUEST_WORKERS, + 60L, + TimeUnit.SECONDS, + new java.util.concurrent.LinkedBlockingQueue<>(10_000), + daemonThreadFactory("server-pool-request"), + new ThreadPoolExecutor.CallerRunsPolicy()); + private final ThreadPoolExecutor connectionExecutor = new ThreadPoolExecutor( + CONNECTION_WORKERS, + CONNECTION_WORKERS, + 60L, + TimeUnit.SECONDS, + new java.util.concurrent.LinkedBlockingQueue<>(10_000), + daemonThreadFactory("server-pool-connect"), + new ThreadPoolExecutor.DiscardPolicy()); + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool( + 2, daemonThreadFactory("server-pool-scheduler")); + + private ServerConnectionPool() {} + + public static ServerConnectionPool getInstance() { + return INSTANCE; + } + + public void startOrLog() { + if (closed.get() || !started.compareAndSet(false, true)) return; + scheduler.scheduleWithFixedDelay(this::refreshKnownPeersSafe, 0L, 30L, TimeUnit.SECONDS); + scheduler.scheduleWithFixedDelay(this::healthCheckSafe, 10L, 10L, TimeUnit.SECONDS); + scheduler.scheduleWithFixedDelay(this::logMetricsSafe, 5L, 5L, TimeUnit.MINUTES); + log.info("Server connection pool started: adaptive ping={}s pongTimeout={}s", + pingIdleSeconds(), pongTimeoutSeconds()); + } + + public JsonNode request( + String serverLogin, + String serverAddress, + String jsonTemplate, + Priority priority + ) throws Exception { + startOrLog(); + String normalizedLogin = normalizeLogin(serverLogin); + if (normalizedLogin == null) { + normalizedLogin = loginFromAddress(serverAddress); + } + if (normalizedLogin == null) { + throw new IllegalArgumentException("Server login and address are empty"); + } + PeerConnection peer = registerPeer(normalizedLogin, serverAddress); + return peer.request(jsonTemplate, priority == null ? Priority.NORMAL : priority); + } + + public PeerConnection registerPeer(String serverLogin, String serverAddress) { + String login = normalizeLogin(serverLogin); + String wsUrl = buildWsUrl(serverAddress); + if (login == null || wsUrl == null) { + throw new IllegalArgumentException("Invalid server peer: login=" + serverLogin + " address=" + serverAddress); + } + PeerConnection peer = peers.computeIfAbsent(login, ignored -> new PeerConnection(login, wsUrl)); + peer.updateWsUrl(wsUrl); + peer.ensureConnectedInBackground(0L); + return peer; + } + + public List snapshotMetrics() { + List result = new ArrayList<>(); + for (PeerConnection peer : peers.values()) result.add(peer.snapshot()); + result.sort(Comparator.comparing(PeerMetricsSnapshot::serverLogin)); + return List.copyOf(result); + } + + private void refreshKnownPeersSafe() { + if (closed.get()) return; + try { + String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); + Map discovered = new LinkedHashMap<>(); + for (SyncServerEntry entry : SyncServersDAO.getInstance().listAll()) { + if (entry == null) continue; + putDiscovered(discovered, ownLogin, entry.getLogin(), entry.getServerAddress()); + } + for (UserAccessServerRouteEntry entry : UserAccessServersCurrentDAO.getInstance().listDistinctServers()) { + if (entry == null) continue; + putDiscovered(discovered, ownLogin, entry.getServerLogin(), entry.getServerUrl()); + } + for (Map.Entry entry : discovered.entrySet()) { + try { + registerPeer(entry.getKey(), entry.getValue()); + } catch (Exception e) { + log.warn("Server pool peer registration failed: server={} reason={}", + entry.getKey(), compactError(e)); + } + } + } catch (Exception e) { + log.warn("Server pool peer refresh failed: {}", compactError(e)); + } + } + + private static void putDiscovered( + Map discovered, + String ownLogin, + String serverLogin, + String serverAddress + ) { + String login = normalizeLogin(serverLogin); + if (login == null || login.equals(ownLogin) || buildWsUrl(serverAddress) == null) return; + discovered.put(login, serverAddress); + } + + private void healthCheckSafe() { + if (closed.get()) return; + for (PeerConnection peer : peers.values()) { + try { + peer.healthCheck(); + } catch (Exception e) { + log.debug("Server pool health check failed: server={} reason={}", + peer.serverLogin, compactError(e)); + } + } + } + + private void logMetricsSafe() { + if (closed.get() || peers.isEmpty()) return; + long connected = peers.values().stream().filter(p -> p.state == ConnectionState.CONNECTED).count(); + int queued = peers.values().stream().mapToInt(p -> p.queuedCount.get()).sum(); + long errors = peers.values().stream().mapToLong(p -> p.failedRequests.get()).sum(); + log.info("Server pool metrics: peers={} connected={} queued={} failedRequests={}", + peers.size(), connected, queued, errors); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + for (PeerConnection peer : peers.values()) peer.closeConnection("pool_shutdown"); + scheduler.shutdownNow(); + requestExecutor.shutdownNow(); + connectionExecutor.shutdownNow(); + peers.clear(); + } + + public enum Priority { + REALTIME(0), + NORMAL(1), + BULK(2); + + private final int rank; + Priority(int rank) { this.rank = rank; } + } + + public enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + CLOSED + } + + public record PeerMetricsSnapshot( + String serverLogin, + String wsUrl, + ConnectionState state, + long connectedAtMs, + long lastActivityAtMs, + long lastPingAtMs, + long lastPongAtMs, + long reconnectCount, + int queuedRealtime, + int queuedNormal, + int queuedBulk, + long successfulRequests, + long failedRequests, + long timedOutRequests, + String lastError + ) {} + + public final class PeerConnection { + private final String serverLogin; + private final PriorityBlockingQueue queue = new PriorityBlockingQueue<>(); + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final AtomicBoolean drainScheduled = new AtomicBoolean(false); + private final AtomicBoolean reconnectScheduled = new AtomicBoolean(false); + private final AtomicInteger queuedCount = new AtomicInteger(); + private final AtomicInteger reconnectAttempt = new AtomicInteger(); + private final AtomicLong generation = new AtomicLong(); + private final AtomicLong reconnectCount = new AtomicLong(); + private final AtomicLong successfulRequests = new AtomicLong(); + private final AtomicLong failedRequests = new AtomicLong(); + private final AtomicLong timedOutRequests = new AtomicLong(); + private final Object connectLock = new Object(); + + private volatile String wsUrl; + private volatile ConnectionState state = ConnectionState.DISCONNECTED; + private volatile WebSocket webSocket; + private volatile CompletableFuture readiness; + private volatile long connectedAtMs; + private volatile long lastActivityAtMs = System.currentTimeMillis(); + private volatile long lastPingAtMs; + private volatile long lastPongAtMs; + private volatile long pingAwaitedSinceMs; + private volatile String lastError = ""; + + private PeerConnection(String serverLogin, String wsUrl) { + this.serverLogin = serverLogin; + this.wsUrl = wsUrl; + } + + private JsonNode request(String jsonTemplate, Priority priority) throws Exception { + if (jsonTemplate == null || jsonTemplate.isBlank()) { + throw new IllegalArgumentException("JSON request template is empty"); + } + int maxQueue = (int) configLong("server.pool.maxQueuePerPeer", 2_000L, 10L, 100_000L); + int queued = queuedCount.incrementAndGet(); + if (queued > maxQueue) { + queuedCount.decrementAndGet(); + throw new IllegalStateException("Server peer queue is full: " + serverLogin); + } + + QueuedRequest task = new QueuedRequest( + priority, + requestSequence.incrementAndGet(), + jsonTemplate, + new CompletableFuture<>()); + queue.offer(task); + scheduleDrain(); + + long timeoutSeconds = configLong("server.pool.callerTimeoutSeconds", 35L, 5L, 120L); + try { + return task.result.get(timeoutSeconds, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + timedOutRequests.incrementAndGet(); + if (queue.remove(task)) queuedCount.decrementAndGet(); + throw new TimeoutException("Server pool caller timeout: " + serverLogin); + } + } + + private void scheduleDrain() { + if (!drainScheduled.compareAndSet(false, true)) return; + requestExecutor.execute(this::drainQueue); + } + + private void drainQueue() { + try { + for (;;) { + QueuedRequest task = queue.poll(); + if (task == null) return; + queuedCount.decrementAndGet(); + if (task.result.isDone()) continue; + try { + ensureConnectedBlocking(); + JsonNode response = sendTemplate(task.jsonTemplate) + .get(requestTimeoutSeconds(), TimeUnit.SECONDS); + successfulRequests.incrementAndGet(); + task.result.complete(response); + } catch (java.util.concurrent.TimeoutException e) { + timedOutRequests.incrementAndGet(); + failedRequests.incrementAndGet(); + task.result.completeExceptionally( + new TimeoutException("Server pool response timeout: " + serverLogin)); + invalidateConnection("response_timeout", e); + } catch (Exception e) { + failedRequests.incrementAndGet(); + task.result.completeExceptionally(unwrap(e)); + if (state != ConnectionState.DISCONNECTED) { + invalidateConnection("request_failed", e); + } + } + } + } finally { + drainScheduled.set(false); + if (!queue.isEmpty()) scheduleDrain(); + } + } + + private void ensureConnectedBlocking() throws Exception { + CompletableFuture localReady; + synchronized (connectLock) { + if (state == ConnectionState.CONNECTED && webSocket != null && !webSocket.isOutputClosed()) return; + if (state == ConnectionState.CLOSED || closed.get()) { + throw new IllegalStateException("Server connection pool is closed"); + } + if (readiness == null || readiness.isDone()) startConnectLocked(); + localReady = readiness; + } + localReady.get(connectAndHelloTimeoutSeconds(), TimeUnit.SECONDS); + } + + private void startConnectLocked() { + long currentGeneration = generation.incrementAndGet(); + state = ConnectionState.CONNECTING; + lastError = ""; + CompletableFuture ready = new CompletableFuture<>(); + readiness = ready; + Listener listener = new Listener(this, currentGeneration); + + http.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener) + .thenCompose(ws -> { + if (generation.get() != currentGeneration) { + try { ws.abort(); } catch (Exception ignored) {} + return CompletableFuture.failedFuture(new IllegalStateException("Superseded server connection")); + } + webSocket = ws; + return sendServerHello(ws); + }) + .whenComplete((ignored, error) -> { + if (error != null) { + ready.completeExceptionally(unwrap(error)); + invalidateConnection(currentGeneration, "connect_or_hello_failed", error); + return; + } + if (generation.get() != currentGeneration) { + ready.completeExceptionally(new IllegalStateException("Superseded server connection")); + return; + } + long now = System.currentTimeMillis(); + connectedAtMs = now; + lastActivityAtMs = now; + lastPongAtMs = now; + pingAwaitedSinceMs = 0L; + reconnectAttempt.set(0); + state = ConnectionState.CONNECTED; + ready.complete(null); + log.info("Server pool connected: server={} url={}", serverLogin, wsUrl); + }); + } + + private CompletableFuture sendServerHello(WebSocket ws) { + try { + String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); + if (ownLogin == null) ownLogin = "unconfigured-server"; + String requestId = "server-hello-" + UUID.randomUUID(); + CompletableFuture response = new CompletableFuture<>(); + pending.put(requestId, response); + String json = """ + { + "op":"ServerHello", + "requestId":%s, + "payload":{ + "serverLogin":%s, + "protocolVersion":1, + "capabilities":["dm-sync","settings-sync","block-sync","connection-pool"] + } + } + """.formatted( + MAPPER.writeValueAsString(requestId), + MAPPER.writeValueAsString(ownLogin)); + lastActivityAtMs = System.currentTimeMillis(); + ws.sendText(json, true).whenComplete((ignored, error) -> { + if (error != null) { + pending.remove(requestId); + response.completeExceptionally(error); + } + }); + return response.orTimeout(requestTimeoutSeconds(), TimeUnit.SECONDS) + .thenAccept(node -> ensureOk("ServerHello", node)); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + private CompletableFuture sendTemplate(String jsonTemplate) throws Exception { + WebSocket ws = webSocket; + if (state != ConnectionState.CONNECTED || ws == null || ws.isOutputClosed()) { + return CompletableFuture.failedFuture(new IllegalStateException("Server peer is disconnected")); + } + String requestId = "server-pool-" + UUID.randomUUID(); + String requestIdJson = MAPPER.writeValueAsString(requestId); + String json = fillRequestId(jsonTemplate, requestIdJson); + CompletableFuture response = new CompletableFuture<>(); + pending.put(requestId, response); + lastActivityAtMs = System.currentTimeMillis(); + ws.sendText(json, true).whenComplete((ignored, error) -> { + if (error != null) { + pending.remove(requestId); + response.completeExceptionally(error); + } + }); + return response; + } + + private void acceptText(long listenerGeneration, String text) { + if (generation.get() != listenerGeneration || text == null || text.isBlank()) return; + lastActivityAtMs = System.currentTimeMillis(); + try { + JsonNode node = MAPPER.readTree(text); + String requestId = node.path("requestId").asText(""); + CompletableFuture response = requestId.isBlank() ? null : pending.remove(requestId); + if (response != null) { + response.complete(node); + } else { + log.debug("Server pool ignored unmatched frame: server={} requestId={} op={}", + serverLogin, requestId, node.path("op").asText("")); + } + } catch (Exception e) { + log.warn("Server pool received invalid JSON: server={} reason={}", serverLogin, compactError(e)); + } + } + + private void healthCheck() { + if (state == ConnectionState.DISCONNECTED) { + ensureConnectedInBackground(reconnectDelayMillis(reconnectAttempt.get())); + return; + } + if (state != ConnectionState.CONNECTED) return; + long now = System.currentTimeMillis(); + if (pingAwaitedSinceMs > 0L) { + if (now - pingAwaitedSinceMs >= TimeUnit.SECONDS.toMillis(pongTimeoutSeconds())) { + invalidateConnection("pong_timeout", new TimeoutException("Pong timeout")); + } + return; + } + if (now - lastActivityAtMs < TimeUnit.SECONDS.toMillis(pingIdleSeconds())) return; + WebSocket ws = webSocket; + if (ws == null || ws.isOutputClosed()) { + invalidateConnection("socket_closed", null); + return; + } + long nonce = now; + pingAwaitedSinceMs = now; + lastPingAtMs = now; + ws.sendPing(ByteBuffer.allocate(Long.BYTES).putLong(0, nonce)) + .whenComplete((ignored, error) -> { + if (error != null) invalidateConnection("ping_failed", error); + }); + } + + private void acceptPong(long listenerGeneration) { + if (generation.get() != listenerGeneration) return; + long now = System.currentTimeMillis(); + lastPongAtMs = now; + lastActivityAtMs = now; + pingAwaitedSinceMs = 0L; + } + + private void updateWsUrl(String newWsUrl) { + if (newWsUrl.equals(wsUrl)) return; + wsUrl = newWsUrl; + invalidateConnection("peer_url_changed", null); + } + + private void ensureConnectedInBackground(long delayMs) { + if (closed.get() || state == ConnectionState.CLOSED) return; + if (!reconnectScheduled.compareAndSet(false, true)) return; + scheduler.schedule(() -> { + reconnectScheduled.set(false); + if (closed.get() || state == ConnectionState.CLOSED || state == ConnectionState.CONNECTED) return; + connectionExecutor.execute(() -> { + try { + ensureConnectedBlocking(); + } catch (Exception e) { + lastError = compactError(e); + // Ошибка connect/ServerHello сама инвалидирует поколение и планирует reconnect. + // Здесь второй schedule дал бы двойной рост backoff для одной попытки. + } + }); + }, Math.max(0L, delayMs), TimeUnit.MILLISECONDS); + } + + private void scheduleReconnect() { + if (closed.get() || state == ConnectionState.CLOSED) return; + int attempt = reconnectAttempt.getAndUpdate(value -> Math.min(value + 1, 30)); + reconnectCount.incrementAndGet(); + ensureConnectedInBackground(reconnectDelayMillis(attempt)); + } + + private long reconnectDelayMillis(int attempt) { + long[] seconds = {1L, 2L, 4L, 8L, 15L, 30L, 60L}; + long base = seconds[Math.min(Math.max(0, attempt), seconds.length - 1)]; + long halfMs = TimeUnit.SECONDS.toMillis(base) / 2L; + long jitterMs = java.util.concurrent.ThreadLocalRandom.current().nextLong(halfMs + 1L); + return halfMs + jitterMs; + } + + private void invalidateConnection(String reason, Throwable error) { + invalidateConnection(generation.get(), reason, error); + } + + private void invalidateConnection(long expectedGeneration, String reason, Throwable error) { + if (state == ConnectionState.CLOSED + || !generation.compareAndSet(expectedGeneration, expectedGeneration + 1L)) return; + lastError = error == null ? reason : reason + ": " + compactError(error); + state = ConnectionState.DISCONNECTED; + pingAwaitedSinceMs = 0L; + WebSocket ws = webSocket; + webSocket = null; + if (ws != null) { + try { ws.abort(); } catch (Exception ignored) {} + } + Exception failure = new IllegalStateException("Server connection lost: " + serverLogin + " (" + reason + ")"); + for (Map.Entry> entry : pending.entrySet()) { + if (pending.remove(entry.getKey(), entry.getValue())) { + entry.getValue().completeExceptionally(failure); + } + } + scheduleReconnect(); + } + + private void closeConnection(String reason) { + state = ConnectionState.CLOSED; + generation.incrementAndGet(); + WebSocket ws = webSocket; + webSocket = null; + if (ws != null) { + try { ws.sendClose(WebSocket.NORMAL_CLOSURE, reason); } catch (Exception ignored) {} + try { ws.abort(); } catch (Exception ignored) {} + } + IllegalStateException failure = new IllegalStateException("Server connection closed: " + serverLogin); + for (CompletableFuture future : pending.values()) future.completeExceptionally(failure); + pending.clear(); + for (QueuedRequest request : queue) request.result.completeExceptionally(failure); + queue.clear(); + queuedCount.set(0); + } + + private PeerMetricsSnapshot snapshot() { + int realtime = 0; + int normal = 0; + int bulk = 0; + for (QueuedRequest request : queue) { + switch (request.priority) { + case REALTIME -> realtime++; + case NORMAL -> normal++; + case BULK -> bulk++; + } + } + return new PeerMetricsSnapshot( + serverLogin, wsUrl, state, connectedAtMs, lastActivityAtMs, + lastPingAtMs, lastPongAtMs, reconnectCount.get(), realtime, normal, bulk, + successfulRequests.get(), failedRequests.get(), timedOutRequests.get(), lastError); + } + } + + private record QueuedRequest( + Priority priority, + long sequence, + String jsonTemplate, + CompletableFuture result + ) implements Comparable { + @Override + public int compareTo(QueuedRequest other) { + int byPriority = Integer.compare(priority.rank, other.priority.rank); + return byPriority != 0 ? byPriority : Long.compare(sequence, other.sequence); + } + } + + private static final class Listener implements WebSocket.Listener { + private final PeerConnection peer; + private final long generation; + private final StringBuilder text = new StringBuilder(); + + private Listener(PeerConnection peer, long generation) { + this.peer = peer; + this.generation = generation; + } + + @Override + public void onOpen(WebSocket webSocket) { + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + text.append(data); + if (last) { + peer.acceptText(generation, text.toString()); + text.setLength(0); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onPong(WebSocket webSocket, ByteBuffer message) { + peer.acceptPong(generation); + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + peer.invalidateConnection(generation, "remote_close_" + statusCode, null); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + peer.invalidateConnection(generation, "websocket_error", error); + } + } + + public static String buildWsUrl(String serverAddressRaw) { + if (serverAddressRaw == null) return null; + String raw = serverAddressRaw.trim(); + if (raw.isEmpty()) return null; + try { + String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw; + URI uri = URI.create(withScheme); + String host = uri.getHost(); + if (host == null || host.isBlank()) return null; + int port = uri.getPort(); + String authority = host.trim().toLowerCase(Locale.ROOT) + (port > 0 ? ":" + port : ""); + return "wss://" + authority + "/ws"; + } catch (Exception e) { + return null; + } + } + + private static String loginFromAddress(String serverAddress) { + String wsUrl = buildWsUrl(serverAddress); + if (wsUrl == null) return null; + try { + return "address:" + URI.create(wsUrl).getAuthority().toLowerCase(Locale.ROOT); + } catch (Exception e) { + return null; + } + } + + private static String normalizeLogin(String value) { + if (value == null) return null; + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.isEmpty() ? null : normalized; + } + + private static void ensureOk(String op, JsonNode response) { + int status = response == null ? 500 : response.path("status").asInt(500); + if (status >= 200 && status < 300) return; + String code = response == null ? "EMPTY_RESPONSE" : response.path("code").asText(""); + if (code.isBlank() && response != null) code = response.path("error").asText(""); + throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); + } + + private static String fillRequestId(String template, String requestIdJson) { + int marker = template.indexOf("%s"); + if (marker < 0) throw new IllegalArgumentException("JSON template has no requestId marker"); + return template.substring(0, marker) + requestIdJson + template.substring(marker + 2); + } + + private static Throwable unwrap(Throwable error) { + Throwable current = error; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static Exception unwrap(Exception error) { + Throwable unwrapped = unwrap((Throwable) error); + return unwrapped instanceof Exception e ? e : new IllegalStateException(unwrapped); + } + + private static String compactError(Throwable error) { + String text = String.valueOf(error == null ? "unknown" : error.getMessage()); + return text.length() <= 500 ? text : text.substring(0, 500); + } + + private static long requestTimeoutSeconds() { + return configLong("server.pool.requestTimeoutSeconds", 12L, 3L, 120L); + } + + private static long connectAndHelloTimeoutSeconds() { + return configLong("server.pool.connectTimeoutSeconds", 15L, 5L, 120L); + } + + private static long pingIdleSeconds() { + return configLong("server.pool.pingIdleSeconds", 120L, 15L, 240L); + } + + private static long pongTimeoutSeconds() { + return configLong("server.pool.pongTimeoutSeconds", 15L, 5L, 120L); + } + + private static long configLong(String key, long defaultValue, long min, long max) { + String raw = AppConfig.getInstance().getParam(key); + if (raw == null || raw.isBlank()) return defaultValue; + try { + long value = Long.parseLong(raw.trim()); + return Math.max(min, Math.min(max, value)); + } catch (Exception ignored) { + return defaultValue; + } + } + + private static ThreadFactory daemonThreadFactory(String prefix) { + return new ThreadFactory() { + private final AtomicLong sequence = new AtomicLong(); + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, prefix + "-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + }; + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index 2eddfe75..f279f65d 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -1091,7 +1091,7 @@ public final class PostgresStorageRepository ELSE u.access_servers_json::jsonb END ) WITH ORDINALITY AS access_server(login_value, ord) - WHERE ord <= 2 + WHERE ord = 1 ) AS access_server JOIN solana_user_pda_current s ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) @@ -1138,7 +1138,7 @@ public final class PostgresStorageRepository ELSE u.access_servers_json::jsonb END ) WITH ORDINALITY AS access_server(login_value, ord) - WHERE ord <= 2 + WHERE ord = 1 ) AS access_server WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login) LOOP diff --git a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java index 4d6755f0..8c79f513 100644 --- a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java +++ b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java @@ -67,7 +67,8 @@ public final class BlockchainResyncRecoveryOnStartup { blockchainName, partnerLogin, partnerAddress); try { - List heads = REMOTE.listBlockchainHeads(partnerAddress); + List heads = + REMOTE.listBlockchainHeads(partnerLogin, partnerAddress); RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream() .filter(h -> h != null && blockchainName.equals(h.blockchainName())) .findFirst() diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index 0b3deebe..a007d58d 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -102,7 +102,7 @@ public final class PeriodicBlockchainSyncService { if (partnerLogin == null) return; List remoteHeads = - REMOTE.listBlockchainHeads(partner.getServerAddress()); + REMOTE.listBlockchainHeads(partner.getLogin(), partner.getServerAddress()); for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) { if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) { @@ -170,7 +170,8 @@ public final class PeriodicBlockchainSyncService { int fromBlockNumber = Math.max(localLast + 1, 0); for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) { RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock = - REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber); + REMOTE.getBlockchainBlock( + partner.getLogin(), partner.getServerAddress(), remoteHead.blockchainName(), blockNumber); if (remoteBlock == null) { log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}", partnerLogin, remoteHead.blockchainName(), blockNumber); @@ -284,7 +285,8 @@ public final class PeriodicBlockchainSyncService { String localPrevHash = ""; for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) { RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock = - REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber); + REMOTE.getBlockchainBlock( + partner.getLogin(), partner.getServerAddress(), blockchainName, blockNumber); if (remoteBlock == null) { log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}", partnerLogin, blockchainName, blockNumber); diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicDmDeliveryService.java b/SHiNE-server/src/main/java/server/sync/PeriodicDmDeliveryService.java new file mode 100644 index 00000000..592193d7 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/PeriodicDmDeliveryService.java @@ -0,0 +1,73 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.entities.DmDeliveryStateEntry; +import utils.config.AppConfig; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Пятисекундный воркер повторной доставки DM получателю. */ +public final class PeriodicDmDeliveryService { + private static final Logger log = LoggerFactory.getLogger(PeriodicDmDeliveryService.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final ScheduledExecutorService SCHEDULER = + Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("dm-worker-dispatcher")); + private static final ThreadPoolExecutor DELIVERY_EXECUTOR = new ThreadPoolExecutor( + 4, 4, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(500), + daemonThreadFactory("dm-delivery"), new ThreadPoolExecutor.DiscardPolicy()); + + private PeriodicDmDeliveryService() {} + + public static void startOrLog() { + if (!isEnabled()) { + log.info("DM delivery worker disabled by dm.delivery.enabled=false"); + return; + } + if (!STARTED.compareAndSet(false, true)) return; + long pollSeconds = configLong("dm.worker.pollSeconds", 5L, 1L, 60L); + SCHEDULER.scheduleWithFixedDelay( + PeriodicDmDeliveryService::tickSafe, 0L, pollSeconds, TimeUnit.SECONDS); + log.info("DM delivery worker scheduled every {} seconds", pollSeconds); + } + + private static void tickSafe() { + try { + int limit = (int) configLong("dm.worker.dueLimit", 100L, 1L, 1000L); + for (DmDeliveryStateEntry row : DmDeliveryCoordinator.listDue(limit)) { + DELIVERY_EXECUTOR.execute(() -> DmDeliveryCoordinator.processDueEntry(row)); + } + } catch (Exception e) { + log.error("DM delivery dispatcher failed", e); + } + } + + private static boolean isEnabled() { + String raw = AppConfig.getInstance().getParam("dm.delivery.enabled"); + return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim()); + } + + private static long configLong(String key, long defaultValue, long min, long max) { + String raw = AppConfig.getInstance().getParam(key); + if (raw == null || raw.isBlank()) return defaultValue; + try { return Math.max(min, Math.min(max, Long.parseLong(raw.trim()))); } + catch (Exception ignored) { return defaultValue; } + } + + private static ThreadFactory daemonThreadFactory(String prefix) { + return new ThreadFactory() { + private int sequence; + @Override public synchronized Thread newThread(Runnable r) { + Thread t = new Thread(r, prefix + "-" + (++sequence)); + t.setDaemon(true); + return t; + } + }; + } +} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java index 94b6c626..6b37e6de 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java @@ -2,165 +2,52 @@ package server.sync; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport; -import shine.db.dao.DmSyncPeerStateDAO; -import shine.db.dao.UserAccessServersCurrentDAO; -import shine.db.entities.DmSyncPeerStateEntry; -import shine.db.entities.UserAccessServerRouteEntry; +import shine.db.entities.DmDeliveryStateEntry; import utils.config.AppConfig; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -/** - * Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя. - */ +/** Пятисекундный воркер отвечает только за доставку DM получателю. */ public final class PeriodicDmSyncService { private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class); - private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; private static final AtomicBoolean STARTED = new AtomicBoolean(false); - - private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); - private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); - private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance(); - - private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r, "periodic-dm-sync"); - t.setDaemon(true); - return t; - } - }); + private static final ScheduledExecutorService SCHEDULER = + Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("dm-worker-dispatcher")); + private static final ThreadPoolExecutor DELIVERY_EXECUTOR = new ThreadPoolExecutor( + 4, 4, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(500), + daemonThreadFactory("dm-delivery"), new ThreadPoolExecutor.DiscardPolicy()); private PeriodicDmSyncService() {} public static void startOrLog() { if (!isEnabled()) { - log.info("Periodic DM sync disabled by dm.sync.enabled=false"); + log.info("DM delivery worker disabled by dm.sync.enabled=false"); return; } - if (!STARTED.compareAndSet(false, true)) { - return; - } - long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L); - long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L); - EXECUTOR.scheduleWithFixedDelay( - PeriodicDmSyncService::runCycleSafe, - initialDelaySec, - TimeUnit.HOURS.toSeconds(periodHours), - TimeUnit.SECONDS - ); - log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours); + if (!STARTED.compareAndSet(false, true)) return; + long pollSeconds = configLong("dm.worker.pollSeconds", 5L, 1L, 60L); + SCHEDULER.scheduleWithFixedDelay( + PeriodicDmSyncService::tickSafe, 0L, pollSeconds, TimeUnit.SECONDS); + log.info("DM delivery worker scheduled every {} seconds", pollSeconds); } - private static void runCycleSafe() { + private static void tickSafe() { try { - runCycle(); + int limit = (int) configLong("dm.worker.dueLimit", 100L, 1L, 1000L); + for (DmDeliveryStateEntry row : DmDeliveryCoordinator.listDue(limit)) { + DELIVERY_EXECUTOR.execute(() -> DmDeliveryCoordinator.processDueEntry(row)); + } } catch (Exception e) { - log.error("Periodic DM sync failed unexpectedly", e); + log.error("DM delivery dispatcher failed", e); } } - private static void runCycle() throws Exception { - String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); - if (ownServerLogin == null) { - log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG); - return; - } - - List ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin); - Set owners = new LinkedHashSet<>(ownersRaw); - if (owners.isEmpty()) { - log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin); - return; - } - - int syncedPeers = 0; - int appliedEvents = 0; - for (String ownerLogin : owners) { - List routes = ACCESS_DAO.listByUserLogin(ownerLogin); - for (UserAccessServerRouteEntry route : routes) { - if (route == null) continue; - String remoteLogin = normalize(route.getServerLogin()); - String remoteUrl = route.getServerUrl(); - if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue; - if (remoteLogin.equals(ownServerLogin)) continue; - - try { - appliedEvents += syncOwnerFromRemote(ownerLogin, route); - syncedPeers++; - } catch (Exception e) { - STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e)); - log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}", - ownerLogin, route.getServerLogin(), String.valueOf(e)); - } - } - } - log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}", - owners.size(), syncedPeers, appliedEvents); - } - - private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception { - int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L); - int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); - int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L); - - DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl()); - long cursorStoredAtMs = state.getCursorStoredAtMs(); - String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey(); - int applied = 0; - boolean bootstrapCompleted = false; - - for (int page = 0; page < maxPages; page++) { - RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch( - route.getServerUrl(), - ownerLogin, - cursorStoredAtMs, - cursorMessageKey, - limit, - maxBytes - ); - - for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) { - if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue; - DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64()); - if (result.status().applied()) { - applied++; - } - } - - cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs()); - cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey(); - bootstrapCompleted = !batch.hasMore(); - STATE_DAO.updateSuccess( - ownerLogin, - route.getServerLogin(), - route.getServerUrl(), - cursorStoredAtMs, - cursorMessageKey, - bootstrapCompleted - ); - - if (!batch.hasMore() || batch.items().isEmpty()) { - break; - } - } - - if (!bootstrapCompleted) { - log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}", - ownerLogin, route.getServerLogin(), maxPages); - } - return applied; - } - private static boolean isEnabled() { String raw = AppConfig.getInstance().getParam("dm.sync.enabled"); return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim()); @@ -169,17 +56,18 @@ public final class PeriodicDmSyncService { private static long configLong(String key, long defaultValue, long min, long max) { String raw = AppConfig.getInstance().getParam(key); if (raw == null || raw.isBlank()) return defaultValue; - try { - long parsed = Long.parseLong(raw.trim()); - return Math.max(min, Math.min(max, parsed)); - } catch (Exception ignored) { - return defaultValue; - } + try { return Math.max(min, Math.min(max, Long.parseLong(raw.trim()))); } + catch (Exception ignored) { return defaultValue; } } - private static String normalize(String value) { - if (value == null) return null; - String s = value.trim().toLowerCase(Locale.ROOT); - return s.isEmpty() ? null : s; + private static ThreadFactory daemonThreadFactory(String prefix) { + return new ThreadFactory() { + private int sequence; + @Override public synchronized Thread newThread(Runnable r) { + Thread t = new Thread(r, prefix + "-" + (++sequence)); + t.setDaemon(true); + return t; + } + }; } } diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java deleted file mode 100644 index b982a910..00000000 --- a/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java +++ /dev/null @@ -1,206 +0,0 @@ -package server.sync; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.dao.UserAccessServersCurrentDAO; -import shine.db.dao.UserSettingsDAO; -import shine.db.dao.UserSettingsSyncPeerStateDAO; -import shine.db.entities.UserAccessServerRouteEntry; -import shine.db.entities.UserSettingEntry; -import shine.db.entities.UserSettingsSyncPeerStateEntry; -import utils.config.AppConfig; - -import java.sql.Connection; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; -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; - -public final class PeriodicUserSettingsSyncService { - private static final Logger log = LoggerFactory.getLogger(PeriodicUserSettingsSyncService.class); - private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; - private static final AtomicBoolean STARTED = new AtomicBoolean(false); - private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient(); - private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); - private static final UserSettingsDAO SETTINGS_DAO = UserSettingsDAO.getInstance(); - private static final UserSettingsSyncPeerStateDAO STATE_DAO = UserSettingsSyncPeerStateDAO.getInstance(); - - private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r, "periodic-user-settings-sync"); - t.setDaemon(true); - return t; - } - }); - - private PeriodicUserSettingsSyncService() {} - - public static void startOrLog() { - if (!isEnabled()) { - log.info("Periodic user settings sync disabled by user.settings.sync.enabled=false"); - return; - } - if (!STARTED.compareAndSet(false, true)) return; - long initialDelaySec = configLong("user.settings.sync.initialDelaySeconds", 90L, 0L, 3600L); - long periodHours = configLong("user.settings.sync.periodHours", 6L, 1L, 168L); - EXECUTOR.scheduleWithFixedDelay( - PeriodicUserSettingsSyncService::runCycleSafe, - initialDelaySec, - TimeUnit.HOURS.toSeconds(periodHours), - TimeUnit.SECONDS - ); - log.info("Periodic user settings sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours); - } - - private static void runCycleSafe() { - try { - runCycle(); - } catch (Exception e) { - log.error("Periodic user settings sync failed unexpectedly", e); - } - } - - private static void runCycle() throws Exception { - String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); - if (ownServerLogin == null) { - log.warn("Periodic user settings sync skipped: {} is empty", SERVER_LOGIN_CONFIG); - return; - } - - List ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin); - Set owners = new LinkedHashSet<>(ownersRaw); - if (owners.isEmpty()) { - log.info("Periodic user settings sync skipped: no local access-server users for {}", ownServerLogin); - return; - } - - int syncedPeers = 0; - int appliedItems = 0; - int pushedItems = 0; - for (String ownerLogin : owners) { - List routes = ACCESS_DAO.listByUserLogin(ownerLogin); - for (UserAccessServerRouteEntry route : routes) { - if (route == null) continue; - String remoteLogin = normalize(route.getServerLogin()); - String remoteUrl = route.getServerUrl(); - if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue; - if (remoteLogin.equals(ownServerLogin)) continue; - - try { - SyncStats stats = syncOwnerWithRemote(ownerLogin, route); - appliedItems += stats.applied(); - pushedItems += stats.pushed(); - syncedPeers++; - } catch (Exception e) { - STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e)); - log.warn("Periodic user settings sync peer failed: owner={} remoteServer={} reason={}", - ownerLogin, route.getServerLogin(), String.valueOf(e)); - } - } - } - - log.info("Periodic user settings sync cycle finished: owners={} syncedPeers={} appliedItems={} pushedItems={}", - owners.size(), syncedPeers, appliedItems, pushedItems); - } - - private static SyncStats syncOwnerWithRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception { - int limit = (int) configLong("user.settings.sync.batchLimit", 500L, 1L, 1000L); - int maxBytes = (int) configLong("user.settings.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); - int maxPages = (int) configLong("user.settings.sync.maxPagesPerPeer", 50L, 1L, 500L); - - UserSettingsSyncPeerStateEntry state; - try (Connection c = getDbConnection()) { - state = STATE_DAO.getOrCreate(c, ownerLogin, route.getServerLogin(), route.getServerUrl()); - } - long cursorTimeMs = state.getCursorTimeMs(); - String cursorSettingKey = state.getCursorSettingKey() == null ? "" : state.getCursorSettingKey(); - int applied = 0; - int pushed = 0; - boolean bootstrapCompleted = false; - - for (int page = 0; page < maxPages; page++) { - RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch( - route.getServerUrl(), - ownerLogin, - cursorTimeMs, - cursorSettingKey, - limit, - maxBytes - ); - - try (Connection c = getDbConnection()) { - for (RemoteUserSettingsSyncClient.RemoteUserSettingsItem item : batch.items()) { - UserSettingEntry entry = new UserSettingEntry( - item.login(), - item.settingType(), - item.settingKey(), - item.timeMs(), - item.valueText(), - item.valueNum(), - item.clientKey(), - item.signature(), - true - ); - int changed = SETTINGS_DAO.upsertIfNewer(c, entry); - if (changed > 0) applied++; - } - } - - cursorTimeMs = Math.max(cursorTimeMs, batch.nextTimeMs()); - cursorSettingKey = batch.nextSettingKey() == null ? "" : batch.nextSettingKey(); - bootstrapCompleted = !batch.hasMore(); - STATE_DAO.updateSuccess(ownerLogin, route.getServerLogin(), route.getServerUrl(), cursorTimeMs, cursorSettingKey, bootstrapCompleted); - - if (!batch.hasMore() || batch.items().isEmpty()) break; - } - - try (Connection c = getDbConnection()) { - List unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit); - for (UserSettingEntry entry : unsynced) { - REMOTE.upsertUserSetting(route.getServerUrl(), entry, true); - SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey()); - pushed++; - } - } - - if (!bootstrapCompleted) { - log.info("Periodic user settings sync peer paused by page limit: owner={} remoteServer={} maxPages={}", - ownerLogin, route.getServerLogin(), maxPages); - } - return new SyncStats(applied, pushed); - } - - private static java.sql.Connection getDbConnection() throws Exception { - return shine.db.DbController.getInstance().getConnection(); - } - - private static boolean isEnabled() { - String raw = AppConfig.getInstance().getParam("user.settings.sync.enabled"); - return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim()); - } - - private static long configLong(String key, long defaultValue, long min, long max) { - String raw = AppConfig.getInstance().getParam(key); - if (raw == null || raw.isBlank()) return defaultValue; - try { - long parsed = Long.parseLong(raw.trim()); - return Math.max(min, Math.min(max, parsed)); - } catch (Exception ignored) { - return defaultValue; - } - } - - private static String normalize(String value) { - if (value == null) return null; - String s = value.trim().toLowerCase(Locale.ROOT); - return s.isEmpty() ? null : s; - } - - private record SyncStats(int applied, int pushed) {} -} diff --git a/SHiNE-server/src/main/java/server/ws/README.md b/SHiNE-server/src/main/java/server/ws/README.md index 9d7a10e4..4ade637d 100644 --- a/SHiNE-server/src/main/java/server/ws/README.md +++ b/SHiNE-server/src/main/java/server/ws/README.md @@ -83,3 +83,11 @@ WebSocket-эндпоинт для одного соединения. - `WsServer` = сервер, который слушает порт и вешает `/ws`. - `BlockchainWsEndpoint` = обработчик одного WebSocket-подключения, мост между сетью и логикой. + +## Постоянные server-to-server соединения + +Исходящие межсерверные JSON-запросы проходят через +`server.sync.ServerConnectionPool`. Пул держит одно постоянное исходящее WSS- +соединение на `serverLogin`, отправляет `ServerHello`, поддерживает канал через +WebSocket ping/pong и переподключает его с backoff. DM, settings и blockchain +используют это соединение с разными приоритетами, не меняя свою бизнес-логику. diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index b504009a..b6186c8f 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -8,10 +8,10 @@ import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; -import server.sync.PeriodicDmSyncService; -import server.sync.PeriodicUserSettingsSyncService; +import server.sync.PeriodicDmDeliveryService; import server.sync.SolanaUsersSyncStartupService; import server.sync.SyncServersBootstrapService; +import server.sync.ServerConnectionPool; import utils.config.AppConfig; import java.time.Duration; @@ -104,8 +104,11 @@ public final class WsServer { server.start(); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); - PeriodicDmSyncService.startOrLog(); - PeriodicUserSettingsSyncService.startOrLog(); + ServerConnectionPool.getInstance().startOrLog(); + Runtime.getRuntime().addShutdownHook(new Thread( + () -> ServerConnectionPool.getInstance().close(), + "server-connection-pool-shutdown")); + PeriodicDmDeliveryService.startOrLog(); server.join(); } } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 17aa12c4..6a0b2c8b 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -34,13 +34,16 @@ sync.importUserProfileFromPartner.enabled=false # ------------------------------------------------------------ server.version=${projectVersion} -# Межсерверная догоняющая синхронизация личных сообщений. -dm.sync.enabled=true -dm.sync.initialDelaySeconds=60 -dm.sync.periodHours=6 -dm.sync.batchLimit=500 -dm.sync.batchMaxBytes=3000000 -dm.sync.maxPagesPerPeer=50 +# Доставка личных сообщений на единственный access-сервер получателя. +dm.delivery.enabled=true +dm.worker.pollSeconds=5 +dm.worker.dueLimit=100 +server.pool.pingIdleSeconds=120 +server.pool.pongTimeoutSeconds=15 +server.pool.requestTimeoutSeconds=12 +server.pool.connectTimeoutSeconds=15 +server.pool.callerTimeoutSeconds=35 +server.pool.maxQueuePerPeer=2000 server.info.url= server.info.physicalRegion= server.info.description= diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index 68d61890..1d85001a 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -5,6 +5,7 @@ import blockchain.body.ConnectionBody; import blockchain.body.CreateChannelBody; import blockchain.body.HeaderBody; import blockchain.body.TextBody; +import shine.db.DbController; import test.it.blockchain.AddBlockSender; import test.it.blockchain.ChainState; import test.it.utils.TestConfig; @@ -13,6 +14,10 @@ import test.it.utils.log.TestResult; import test.it.utils.ws.WsSession; import java.time.Duration; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import static org.junit.jupiter.api.Assertions.*; @@ -117,6 +122,27 @@ public class IT_03_AddBlock_NoAuth { st1.registerTextChannelRoot(newsRootBlock, newsRootHash); } + // CREATE_CHANNEL "Updates" — второй публичный канал того же владельца + int updatesRootBlock; + byte[] updatesRootHash; + { + var ln = st1.nextLineByType(ChainState.TYPE_TECH); + sender1.send(new CreateChannelBody( + 0, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "Updates", + "", + CreateChannelBody.CHANNEL_TYPE_PUBLIC, + CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT + ), t); + + updatesRootBlock = st1.lastBlockNumber(); + updatesRootHash = st1.getHash32(updatesRootBlock); + assertNotNull(updatesRootHash); + + st1.registerTextChannelRoot(updatesRootBlock, updatesRootHash); + } + // POST #0 в канал "News" int newsPost0Block; byte[] newsPost0Hash; @@ -188,7 +214,21 @@ public class IT_03_AddBlock_NoAuth { bch1, newsRootBlock, newsRootHash, "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); - // 3) FRIEND взаимно (на HEADER) + // 3) U2 подписался на второй канал U1 "Updates" + sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, + bch1, updatesRootBlock, updatesRootHash, + "U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t); + + assertEquals(2, countConnectionsByOwner(u2, u1), + "U2 должен иметь две отдельные записи подписки на каналы U1"); + assertEquals(2, countFollowingChannels(u2), + "following_channels_count должен учитывать два разных канала одного владельца"); + assertEquals(1, countSubscribers(bch1, newsRootBlock, newsRootHash), + "У канала News должен быть 1 подписчик"); + assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash), + "У канала Updates должен быть 1 подписчик"); + + // 4) FRIEND взаимно (на HEADER) sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: FRIEND", t); @@ -197,7 +237,7 @@ public class IT_03_AddBlock_NoAuth { bch1, u1HeaderBlock, u1HeaderHash, "U2 -> U1: FRIEND", t); - // 4) CONTACT несколько + // 5) CONTACT несколько sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: CONTACT", t); @@ -236,7 +276,21 @@ public class IT_03_AddBlock_NoAuth { bch3, u3HeaderBlock, u3HeaderHash, "U1 -> U3: CONTACT", t); - // 5) U1 убирает U2 из контактов (UNCONTACT) + // 6) U2 отписывается только от News + sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW, + bch1, newsRootBlock, newsRootHash, + "U2 unfollows U1 channel 'News'", t); + + assertEquals(1, countConnectionsByOwner(u2, u1), + "После отписки от одного канала должна остаться одна запись подписки"); + assertEquals(1, countFollowingChannels(u2), + "following_channels_count должен уменьшиться ровно на 1"); + assertEquals(0, countSubscribers(bch1, newsRootBlock, newsRootHash), + "После отписки от News у канала должен остаться 0 подписчиков"); + assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash), + "Подписка на Updates должна остаться"); + + // 7) U1 убирает U2 из контактов (UNCONTACT) sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: UNCONTACT", t); @@ -288,4 +342,58 @@ public class IT_03_AddBlock_NoAuth { toBlockHash32 ), timeout); } + + private static int countConnectionsByOwner(String login, String ownerLogin) { + return queryInt(""" + SELECT COUNT(*) + FROM connections_state + WHERE login = ? + AND rel_type = 30 + AND to_login = ? + """, login, ownerLogin); + } + + private static int countFollowingChannels(String login) { + return queryInt(""" + SELECT following_channels_count + FROM user_stats_state + WHERE login = ? + """, login); + } + + private static int countSubscribers(String ownerBlockchainName, int rootBlockNumber, byte[] rootBlockHash) { + return queryInt(""" + SELECT COALESCE(subscribers_count, 0) + FROM channel_stats_state + WHERE owner_bch_name = ? + AND channel_root_block_number = ? + AND channel_root_block_hash = ? + """, ownerBlockchainName, rootBlockNumber, rootBlockHash); + } + + private static int queryInt(String sql, Object... params) { + try (Connection c = DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + for (int i = 0; i < params.length; i++) { + Object p = params[i]; + if (p instanceof String s) { + ps.setString(i + 1, s); + } else if (p instanceof Integer n) { + ps.setInt(i + 1, n); + } else if (p instanceof byte[] bytes) { + ps.setBytes(i + 1, bytes); + } else { + throw new IllegalArgumentException("Unsupported SQL param type: " + (p == null ? "null" : p.getClass())); + } + } + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new IllegalStateException("Query returned no rows: " + sql); + } + return rs.getInt(1); + } + } catch (SQLException e) { + throw new RuntimeException("DB query failed: " + sql, e); + } + } } diff --git a/VERSION.properties b/VERSION.properties index e16535eb..59887d7d 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.9.0 -server.version=1.7.0 +client.version=1.10.0 +server.version=1.8.0 diff --git a/bundle.sh b/bundle.sh index 16adab75..e3d1dc27 100644 --- a/bundle.sh +++ b/bundle.sh @@ -124,6 +124,14 @@ done < "$TMP/files.z" sort -u "$LIST" -o "$LIST" +# Always include Gradle wrapper bootstrap, even though generic JARs are denied. +if [[ -f "$ROOT/SHiNE-server/gradle/wrapper/gradle-wrapper.jar" ]]; then + if ! grep -Fxq 'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' "$LIST"; then + printf '%s\n' 'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' >> "$LIST" + sort -u "$LIST" -o "$LIST" + fi +fi + # Content scan: fail closed on common credential/private-key patterns. # We scan only text-ish files; grep -I skips binary data. SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}' diff --git a/deploy/backup/backup-version.properties b/deploy/backup/backup-version.properties index 72dd5eb9..17bc0c72 100644 --- a/deploy/backup/backup-version.properties +++ b/deploy/backup/backup-version.properties @@ -1,3 +1,3 @@ backup.schema.version=2 -backup.full.version=3 -last.full.backup.date=2026-08-08 +backup.full.version=4 +last.full.backup.date=2026-08-25 diff --git a/docs/API/01_User_Registration_API.md b/docs/API/01_User_Registration_API.md index ba411334..25ccd9ce 100644 --- a/docs/API/01_User_Registration_API.md +++ b/docs/API/01_User_Registration_API.md @@ -57,7 +57,11 @@ "serverLastGlobalNumber": 128, "serverLastGlobalHash": "4f...ab", "serverBlockchainSizeBytes": 45212, - "serverBlockchainSizeLimitBytes": 100000 + "serverBlockchainSizeLimitBytes": 100000, + "ownedPublicChannelsCount": 3, + "followingUsersCount": 18, + "followingChannelsCount": 27, + "closeFriendsCount": 4 } } ``` @@ -68,6 +72,10 @@ - `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа); - `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах; - `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах; +- `ownedPublicChannelsCount` — количество публичных каналов, владельцем которых является пользователь; +- `followingUsersCount` — количество пользователей, на которых подписан пользователь; +- `followingChannelsCount` — количество публичных каналов, на которые подписан пользователь; +- `closeFriendsCount` — количество близких друзей пользователя. ### Успешный ответ: пользователя нет diff --git a/docs/API/02_Authentication_API.md b/docs/API/02_Authentication_API.md index 470434b4..299ed4f0 100644 --- a/docs/API/02_Authentication_API.md +++ b/docs/API/02_Authentication_API.md @@ -4,6 +4,7 @@ Здесь четыре базовых метода обычной авторизации: +- `ResolveLoginForAuth` - `AuthChallenge` - `CreateAuthSession` - `SessionChallenge` @@ -67,6 +68,57 @@ ed25519/BASE64_PUBLIC_KEY --- +## 1.1. `ResolveLoginForAuth` + +Первый шаг интерактивного входа. Операция не заменяет `GetUser` и не меняет его семантику. +Она предназначена только для выбора правильного access server перед вводом пароля +или запуском входа через доверенное устройство. + +Запрос: + +```json +{ + "op": "ResolveLoginForAuth", + "requestId": "login-route-1", + "payload": { + "login": "alice" + } +} +``` + +Успешный ответ всегда имеет `status=200`, а `payload.resolution` принимает одно из значений: + +- `LOCAL` — пользователь существует, и `access_servers[0]` указывает на этот сервер; +- `REMOTE` — пользователь существует, но его первый access server другой; +- `NOT_FOUND` — пользователя нет в локально синхронизированном Solana PDA snapshot; +- `NO_ACCESS_SERVER` — пользователь существует, но корректный первый access server не найден. + +Для `LOCAL` и `REMOTE` сервер возвращает канонический `login`. Для `REMOTE` +также возвращаются `accessServerLogin` и `accessServerUrl`, чтобы UI сразу мог +перенаправить пользователя на правильную точку входа. + +Пример `REMOTE`: + +```json +{ + "op": "ResolveLoginForAuth", + "requestId": "login-route-1", + "status": 200, + "ok": true, + "payload": { + "resolution": "REMOTE", + "login": "Alice", + "accessServerLogin": "shine-node-2", + "accessServerUrl": "wss://node2.example.org/ws" + } +} +``` + +Важно: это UX-проверка, а не security boundary. `AuthChallenge/CreateAuthSession` +повторно определяют отношение пользователя к текущему серверу. Пользователь другого +access server может криптографически авторизоваться напрямую, но получает +`connectionScope=REMOTE`. + ## 2. `AuthChallenge` ### Запрос @@ -339,3 +391,33 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} } } ``` + +### LOCAL/REMOTE scope успешной авторизации + +`CreateAuthSession` и `SessionLogin` дополнительно возвращают: + +```json +{ + "connectionScope": "LOCAL" +} +``` + +или: + +```json +{ + "connectionScope": "REMOTE" +} +``` + +`REMOTE` означает, что пользователь успешно доказал владение ключом/сессией, но +текущий сервер не является его `access_servers[0]`. Такой WebSocket регистрируется +как активное соединение и может использоваться для realtime звонков, однако +центральная политика сервера разрешает ему только технические и call-signaling +операции. Обычные homeserver-операции отвечают `403 / USER_NOT_LOCAL`. + +Разрешённый минимальный набор для `REMOTE`: `Ping`, `GetServerInfo`, +`GetCallIceConfig`, `CallInviteBroadcast`, `CallSignalToSession`, `SendSignal`, +`CallDeliveryReport`, `ClientErrorLog`, `ClientDebugLog`, `CloseActiveSession` +и `ResolveLoginForAuth`. + diff --git a/docs/API/03_Session_Management_API.md b/docs/API/03_Session_Management_API.md index d7c28a04..6d304b49 100644 --- a/docs/API/03_Session_Management_API.md +++ b/docs/API/03_Session_Management_API.md @@ -313,9 +313,14 @@ TTL заявки фиксирован на сервере и сейчас все - `400 / BAD_PAYLOAD_TYPE` - `422 / PAIRING_NOT_AVAILABLE` - `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля. -- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся. +Код создаётся даже если сейчас нет ни одной онлайн доверённой сессии пользователя. +В таком случае `trustedSessionOnline=false`, а pairing-заявка остаётся в состоянии +`created` до подтверждения, отмены или истечения TTL. - `429 / PAIRING_RATE_LIMITED` +Также `StartTrustedDeviceLogin` разрешён только на первом access server пользователя. +На другом сервере операция возвращает `409 / USER_NOT_LOCAL`. + ### 5.4. `ListTrustedDeviceLoginRequests` Доступно для любой уже авторизованной доверенной сессии пользователя. diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index b2e24c69..7cd54351 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -158,6 +158,7 @@ "avaSha256": "0123...", "avaSize": 248193, "metaUpdatedAtMs": 1760000000000, + "subscribersCount": 128, "channelRoot": { "blockNumber": 123, "blockHash": "..." } }, "metaEvents": [ diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 2716626c..8cf2e7d1 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -16,6 +16,7 @@ | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара | | `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave | +| `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | @@ -32,6 +33,7 @@ | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | | `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна | +| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки | | `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | | `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов | @@ -62,13 +64,9 @@ | `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | -| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` | -| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока | +| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | внутренняя доставка входящего DM-блока на единственный сервер получателя | | `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон | | `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки | -| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору | -| `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору | -| `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных | | `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога | | `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию | | `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку | @@ -76,7 +74,11 @@ ## Важные замечания -- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. +- UI отправляет полную подписанную пару только через `SendMessagePair`. +- `ReceiveIncomingMessage` вызывается сервером отправителя для доставки одной + входящей копии на единственный access-сервер получателя. +- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее. +- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией. - Отдельных HTTP endpoints для DM-файлов сейчас нет. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index 2efb1682..e0c2b342 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -9,8 +9,10 @@ Важно: -- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; -- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. +- UI использует `SendMessagePair`, `DeleteMessage`, `DeleteConversation` + и `GetDirectMessages`; +- `ReceiveIncomingMessage` — внутренняя доставка одной входящей копии на + единственный access-сервер получателя. - сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг. - в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса. @@ -69,9 +71,7 @@ } ``` -## 3. `SendMessagePair` и `ReceiveOutcomingMessage` - -`ReceiveOutcomingMessage` — алиас `SendMessagePair`. +## 3. `SendMessagePair` ### Назначение @@ -107,12 +107,20 @@ "baseKey": "from|to|time|nonce", "incomingKey": "from|to|time|nonce|1", "outgoingKey": "from|to|time|nonce|2", + "deliveryState": "accepted", "deliveredWsSessions": 1, "deliveredWebPushSessions": 0 } } ``` +Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту +сервер пробует единственный актуальный сервер получателя. Поэтому +`deliveryState` уже может быть `delivered`; если сервер получателя не +ответил, возвращается `accepted`. + +Возможные `deliveryState`: `accepted`, `delivered`, `failed`. + ### Ошибки - `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` @@ -127,7 +135,8 @@ ### Назначение -Используется там, где нужно принять только incoming-вариант сообщения. +Используется сервером отправителя для доставки incoming-варианта сообщения на +единственный access-сервер получателя. Принимаемые типы: @@ -147,7 +156,11 @@ } ``` -`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику. +`sourceServerLogin` пока передаётся для диагностики маршрута. Межсерверная +авторизация остаётся отдельным будущим этапом, но пользовательская подпись +signed-блока проверяется всегда. Официальный UI эту операцию не вызывает. + +Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно. ### Примечание @@ -243,6 +256,7 @@ "reencryptedAtMs": 0, "createdAtMs": 1774700001123, "readAtMs": 1774700001456, + "deliveryState": "delivered", "blobB64": "BASE64_SIGNED_BLOCK" } ] @@ -252,67 +266,30 @@ Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа. -## 8. `DmSyncBatch` +Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`. -Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`. +## 8. Внутренняя межсерверная доставка DM -### Запрос +### 8.1. `ReceiveIncomingMessage` + +Передаёт одну входящую копию на единственный access-сервер получателя. ```json { - "op": "DmSyncBatch", - "requestId": "dm-sync-001", + "op": "ReceiveIncomingMessage", + "requestId": "dm-incoming-001", "payload": { - "ownerLogin": "alice", - "afterStoredAtMs": 1774700000000, - "afterMessageKey": "alice|bob|1774699999000|123456780|2", - "limit": 500, - "maxBytes": 3000000 + "incomingBlobB64": "BASE64_INCOMING", + "sourceServerLogin": "server-a" } } ``` -`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`. +Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись). -### Успешный ответ - -```json -{ - "op": "DmSyncBatch", - "requestId": "dm-sync-001", - "status": 200, - "ok": true, - "payload": { - "ownerLogin": "alice", - "limit": 500, - "rawBytes": 84512, - "hasMore": true, - "nextStoredAtMs": 1774700100000, - "nextMessageKey": "alice|bob|1774700000123|123456789|1", - "items": [ - { - "messageKey": "alice|bob|1774700000123|123456789|1", - "baseKey": "alice|bob|1774700000123|123456789", - "targetLogin": "alice", - "fromLogin": "bob", - "toLogin": "alice", - "messageType": 1, - "timeMs": 1774700000123, - "storedAtMs": 1774700100000, - "blobB64": "BASE64_SIGNED_BLOCK" - } - ] - } -} -``` - -События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`. - -Ошибки: - -- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin` -- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя -- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login` +Операции `ReceiveOutcomingMessage`, `DmSyncBatch` и +`GetDmDeliveryStatus` удалены вместе с репликацией между access-серверами +одного пользователя. ## 9. `AckSessionDelivery` @@ -355,6 +332,25 @@ Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. +## 10.1. Событие `DmDeliveryStateChanged` + +Сервер отправляет событие активным сессиям отправителя при изменении сетевого состояния исходящей пары. + +```json +{ + "op": "DmDeliveryStateChanged", + "event": true, + "status": 200, + "payload": { + "baseKey": "alice|bob|1774700000123|123456789", + "outgoingKey": "alice|bob|1774700000123|123456789|2", + "deliveryState": "delivered" + } +} +``` + +`failed` приходит после последней неудачной попытки через час. Событие прочтения остаётся отдельным существующим read-receipt. + ## 11. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. diff --git a/docs/API/13_User_Settings_API.md b/docs/API/13_User_Settings_API.md index 27085f58..e496eead 100644 --- a/docs/API/13_User_Settings_API.md +++ b/docs/API/13_User_Settings_API.md @@ -1,99 +1,57 @@ -# API пользовательских настроек +# User Settings API -Этот раздел описывает `user_settings` - отдельное хранилище пользовательских настроек, не связанное с `users_params` и не связанное с legacy DM-таблицами. +user_settings хранит технические настройки пользователя только на его +единственном действующем access-сервере. Межсерверной синхронизации настроек +нет. При смене access-сервера старые настройки автоматически не переносятся. -## 1. Назначение +## Подпись -`user_settings` хранит технические пользовательские настройки, которые должны синхронизироваться между максимум двумя access/sync-серверами пользователя. +UI формирует строку: -Основной текущий кейс: +SHiNe/UserSettings:||||| -- `setting_type = 1` - курсор прочитанности канала; -- `setting_key = ownerBlockchainName/channelName`; -- `value_num = number of messages already seen in channel`; -- `value_text = ''`. +и подписывает её текущим Ed25519 clientKey пользователя. Сервер: -Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`. -После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения. +1. находит пользователя; +2. проверяет совпадение client_key с актуальным ключом пользователя; +3. строго проверяет подпись; +4. при неверной подписи возвращает INVALID_SIGNATURE; +5. сохраняет запись только если time_ms новее локальной версии. -## 2. Структура записи +## Операции -- `login` - логин владельца настройки; -- `setting_type` - числовой код типа настройки; -- `setting_key` - строковый ключ настройки; -- `time_ms` - время установки значения в миллисекундах; -- `value_text` - строковое значение; -- `value_num` - числовое значение; -- `client_key` - публичный Ed25519 ключ клиента в Base64; -- `signature` - Ed25519 подпись preimage в Base64; -- `synced` - была ли настройка успешно доставлена на второй сервер. +### UpsertUserSetting -Уникальность: `(login, setting_type, setting_key)`. -Обновление: только если `time_ms` новее текущего значения. +Обязательные поля: -## 3. Формат подписи +- login; +- setting_type; +- setting_key; +- time_ms; +- client_key; +- signature. -Подписывается строка: +Значения: -`SHiNe/UserSettings:|login|setting_type|setting_key|time_ms|value_text|value_num` +- value_text — строковое значение, по умолчанию пустая строка; +- value_num — числовое значение, по умолчанию 0. -Где внутри полей используется экранирование `\` и `|`. +Поля sync_delivery и synced удалены. -Подпись создаётся клиентским `client_key`. +### GetUserSetting -## 4. Операции +Возвращает одну локальную настройку по login, setting_type и setting_key. -### `UpsertUserSetting` +### ListUserSettings -Записывает или обновляет настройку пользователя. +Возвращает все локальные настройки пользователя. -Если запрос пришёл от клиента, сервер: +## Удалённые операции -- сохраняет запись локально; -- пытается сразу отправить её на доступный sync-сервер; -- если отправка успешна, помечает запись как `synced=true`; -- если нет, оставляет `synced=false`. +После перехода на один access-сервер удалены: -Если запрос пришёл по синхронизации между серверами, используется `sync_delivery=true`, и повторной пересылки дальше не делается. +- UserSettingsSyncBatch; +- MarkAllUserSettingsUnsynced. -### `GetUserSetting` - -Чтение одной настройки по `(login, setting_type, setting_key)`. - -### `ListUserSettings` - -Список всех настроек пользователя. - -### `UserSettingsSyncBatch` - -Внутренний межсерверный batch-эндпоинт. - -- отдаёт настройки, новые относительно курсора; -- используется для bootstrap и догрузки после восстановления; -- применяется только для пользователей, чей сервер есть в `access_servers`. - -### `MarkAllUserSettingsUnsynced` - -Внутренний служебный запрос. - -- помечает все настройки пользователя или все настройки сразу как `synced=false`; -- нужен после добавления нового sync-сервера или при потере локальной БД. - -## 5. Синхронизация - -Синхронизация настроек работает отдельно от DM. - -- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер; -- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`; -- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору; -- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля. - -## 6. Текущий UI-кейс - -UI при открытии канала отправляет `UpsertUserSetting` с: - -- `setting_type = 1`; -- `setting_key = ownerBlockchainName/channelName`; -- `value_num = количество уже просмотренных сообщений в канале`. - -Это значение используется сервером для расчёта unread в списке каналов и в канале. +Также удалены таблица peer-cursor и периодический сервис синхронизации +настроек. diff --git a/docs/API/16_Server_Connection_Pool_API.md b/docs/API/16_Server_Connection_Pool_API.md new file mode 100644 index 00000000..333ca2ab --- /dev/null +++ b/docs/API/16_Server_Connection_Pool_API.md @@ -0,0 +1,106 @@ +# Межсерверное соединение и `ServerHello` + +Документ описывает транспортный слой постоянных WSS-соединений между серверами SHiNE. +Он не меняет форматы DM, блоков, настроек, ACK или расписания повторных попыток. + +## 1. `ServerHello` + +После установления исходящего WSS-соединения сервер первым запросом отправляет: + +```json +{ + "op": "ServerHello", + "requestId": "server-hello-001", + "payload": { + "serverLogin": "shineupme", + "protocolVersion": 1, + "capabilities": [ + "dm-sync", + "settings-sync", + "block-sync", + "connection-pool" + ] + } +} +``` + +Успешный ответ: + +```json +{ + "op": "ServerHello", + "requestId": "server-hello-001", + "status": 200, + "payload": { + "accepted": true, + "serverLogin": "server2", + "protocolVersion": 1, + "capabilities": [ + "dm-sync", + "settings-sync", + "block-sync", + "connection-pool" + ] + } +} +``` + +На текущем этапе `serverLogin` принимается на доверии. Подпись, challenge и +проверка корневого ключа сервера намеренно отложены. Поэтому `ServerHello` +фиксирует тип соединения и возможности peer, но пока не является +криптографической аутентификацией. + +## 2. Пул соединений + +- физическое соединение создаётся одно на `serverLogin`; +- логические операции DM, settings и blockchain используют один WSS; +- завершение `RemoteSyncSession` не закрывает физический сокет; +- известные peer берутся из `sync_servers` и первых действующих маршрутов + `user_access_servers_current`; +- список перечитывается каждые 30 секунд; +- при изменении URL соединение пересоздаётся; +- новые запросы не повторяются транспортом автоматически: действующие + domain-воркеры сохраняют прежние правила retry и идемпотентности. + +## 3. Приоритеты + +| Приоритет | Операции | +| --- | --- | +| `REALTIME` | доставка DM и signed deletes | +| `NORMAL` | прочие межсерверные запросы | +| `BULK` | blockchain heads, blocks, `AddBlock` backfill | + +На одном peer одновременно исполняется один запрос. Приоритет применяется к +ожидающей очереди и не прерывает уже начатую операцию. + +## 4. Ping, reconnect и таймауты + +Значения по умолчанию: + +| Параметр | Значение | +| --- | ---: | +| `server.pool.pingIdleSeconds` | `120` | +| `server.pool.pongTimeoutSeconds` | `15` | +| `server.pool.requestTimeoutSeconds` | `12` | +| `server.pool.connectTimeoutSeconds` | `15` | +| `server.pool.callerTimeoutSeconds` | `35` | +| `server.pool.maxQueuePerPeer` | `2000` | + +Ping отправляется WebSocket control-frame только после периода отсутствия +полезного трафика. При потере соединения применяется reconnect с jitter и +ступенями до 60 секунд. + +## 5. Диагностика + +Внутренний snapshot пула хранит для каждого peer: + +- состояние соединения; +- URL; +- времена connect/activity/ping/pong; +- число reconnect; +- размеры очередей по приоритетам; +- успешные, ошибочные и просроченные запросы; +- последнюю ошибку. + +Агрегированное состояние периодически записывается в серверный лог. Отдельная +публичная операция метрик на этом этапе не добавляется. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 33b7595c..9f1f6577 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,18 @@ # История изменений документации блокчейна +## 2026-08-25 19:45:18 +0400 +- Базовый коммит-ориентир: `3a58519`. +- Добавлены runtime-агрегаты статистики: + - `user_stats_state` хранит `owned_public_channels_count`, `following_users_count`, `following_channels_count`, `close_friends_count`; + - `channel_stats_state` хранит `subscribers_count` для публичных каналов. +- Обновление статистики выполняется атомарно вместе с записью блока в той же SQL-транзакции `AddBlock`. +- После полного resync статистика пересобирается из текущих `connections_state` и `channel_names_state`, чтобы старые данные продолжали работать. + +## 2026-08-25 19:59:00 +0400 +- Базовый коммит-ориентир: `working tree`. +- Триггер `channel_names_state` для статистики сделан устойчивым к старым данным, где канал уже есть, а владельца ещё нет в `solana_user_pda_current`. +- Добавлена совместимая миграция `v15`, чтобы существующие базы перестали падать на старых каналах при старте сервера. + ## 2026-08-12 13:00:00 +0400 - Базовый коммит-ориентир: `working tree`. - Канонический текстовый формат служебных тегов сокращён: diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 17958027..d00ba6c2 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -33,7 +33,7 @@ ## Смежная документация - [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор. - [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий). -- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блоков и DM. +- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блокчейна и доставка DM на единственный сервер получателя. ## Важные ограничения MVP - Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI. @@ -42,4 +42,6 @@ ## Обязательное сопровождение - При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений. - Обычный `AddBlock` сейчас пишет через `.tmp_bch`, `.write_check` и `.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery. +- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync. +- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA. - Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index 6c08aac4..c839e2b7 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -1,4 +1,4 @@ -# Синхронизация блоков и DM между серверами SHiNE +# Синхронизация блокчейнов и доставка DM между серверами SHiNE Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. @@ -19,9 +19,9 @@ Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. -Важно: в текущей архитектуре у пользователя одновременно может быть не более -двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики -`synced` и пользовательских курсоров. +`sync_servers` относится к серверному узлу и не является списком +access-серверов обычного пользователя. У пользователя действует только первый +`access_servers[0]`. - Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. - Адрес каждого партнёрского сервера читается из его PDA на Solana. @@ -32,9 +32,11 @@ ### 3.1 Личные сообщения (DM) - Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt). -- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя. -- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии. +- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу. +- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям. - Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. +- Между access-серверами одного пользователя DM не реплицируются. +- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. ### 3.2 Блоки пользовательского блокчейна @@ -45,10 +47,8 @@ ### 3.3 Пользовательские настройки -- Отдельная таблица `user_settings`. -- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов. -- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`. -- Синхронизация идёт с учётом `time_ms` и флага `synced`. +Пользовательские настройки хранятся локально на единственном access-сервере и +между серверами не синхронизируются. ## 4. Текущая реализованная схема @@ -181,25 +181,32 @@ Full resync запускается только тогда, когда: Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. -## 5. Целевой протокол следующего этапа +## 5. Реализованный постоянный server-to-server транспорт + +Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает +единый постоянный WSS-транспорт, через который выполняются уже существующие операции. ### 5.1 Межсерверное соединение -- Серверы устанавливают постоянное WebSocket-соединение друг с другом. +- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом. - Адрес партнёра определяется по `server_address` из его Solana PDA. -- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA). -- При разрыве — переподключение с экспоненциальным backoff. +- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities. +- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена. +- При разрыве выполняется переподключение с jitter/backoff до 60 секунд. +- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд. +- Один физический канал переиспользуют доставка DM и blockchain. ### 5.2 Доставка новых данных (push) -- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам. +- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам. - Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. +- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. ### 5.3 Начальная синхронизация (backfill) -- При первом подключении к партнёру серверы обмениваются «курсорами» состояния: - последний глобальный номер блока, последний известный DM-ключ. +- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов. - Сервер с более полной историей досылает недостающее партнёру. +- DM-history backfill между access-серверами отсутствует. ### 5.4 Разрешение конфликтов @@ -212,19 +219,21 @@ Full resync запускается только тогда, когда: При отправке DM от пользователя A к пользователю B: 1. Клиент A отправляет пару блоков на свой сервер X. -2. Сервер X определяет, на каком сервере зарегистрирован пользователь B. - - Сначала проверяет локально (если B зарегистрирован на X). - - Иначе читает PDA пользователя B из Solana и смотрит `access_servers`. - - Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM. -3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки. +2. Сервер X валидирует и локально сохраняет пару. +3. До ответа клиенту X отправляет входящую копию на единственный + `access_servers[0]` пользователя B через `ReceiveIncomingMessage`. +4. Успешное сохранение на сервере B означает терминальный `delivered`. +5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. +6. После неудачной попытки через час ставится терминальный `failed`. -Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения). +Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке. ## 7. Безопасность - Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. -- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа). +- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами. - При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. +- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным. ## 8. Статус реализации @@ -239,16 +248,20 @@ Full resync запускается только тогда, когда: | Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | | Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | | Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | -| Межсерверный постоянный WebSocket-канал | Нужна реализация | -| Push новых DM партнёрам | Нужна реализация | -| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | +| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` | +| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано | +| Retry DM до 1 часа + UI-state | ✅ Реализовано | +| Репликация DM и настроек между access-серверами | Не используется | +| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул | | Periodic backfill отсутствующего хвоста | ✅ Реализовано | | Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | | Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | -| Маршрутизация DM через access_servers | Нужна реализация (заглушка) | +| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано | +| Криптографическая server-to-server авторизация DM | Нужна реализация | -Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами. -Не реализованы ещё DM-sync и постоянные server-to-server соединения. +Текущая версия сервера использует постоянный WSS-пул для существующих +server-to-server JSON-операций. Отдельной будущей задачей остаётся +криптографическая авторизация server-to-server вызовов. Следующие отдельные шаги после текущего этапа: - отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 0f24f649..7c92140b 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -6,6 +6,8 @@ - `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` - `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` +- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на + единственный access-сервер, retry-воркер и UI-статусы - `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки Исторический устаревший документ сохранён отдельно: diff --git a/docs/Personal_Messages/Доставка_и_синхронизация_DM.md b/docs/Personal_Messages/Доставка_и_синхронизация_DM.md new file mode 100644 index 00000000..c14ddda5 --- /dev/null +++ b/docs/Personal_Messages/Доставка_и_синхронизация_DM.md @@ -0,0 +1,84 @@ +# Доставка личных сообщений + +## 1. Инвариант + +У каждого пользователя действует ровно один сервер доступа: первый элемент +access_servers[0] в его Solana PDA. + +Остальные элементы старой PDA, если они существуют, не участвуют в +маршрутизации и не являются fallback. UI позволяет только заменить текущий +сервер другим. + +Межсерверной репликации личной переписки между серверами одного пользователя +нет. При смене сервера история автоматически не переносится. + +## 2. Подписанные копии + +Формат SHiNE_DM не меняется. Для сообщения клиент создаёт: + +- входящую копию для получателя; +- исходящую копию для отправителя. + +Сервер отправителя принимает пару через SendMessagePair, атомарно сохраняет её +и создаёт изменяемое состояние доставки. + +## 3. Основной маршрут + +1. UI отправителя вызывает SendMessagePair на своём access-сервере. +2. Сервер сохраняет исходящую копию отправителя и входящую копию, необходимую + для доставки и повторных попыток. +3. Сервер читает единственный маршрут получателя из + user_access_servers_current. +4. Если это другой физический сервер, он вызывает внутреннюю операцию + ReceiveIncomingMessage и передаёт только входящую копию. +5. Сервер получателя проверяет пользовательскую подпись, идемпотентно сохраняет + входящую копию и уведомляет активные сессии. +6. Успешное сохранение означает delivered. + +ReceiveIncomingMessage не является синхронизацией двух серверов одного +пользователя. Это основная доставка между серверами разных пользователей. + +## 4. Повторные попытки + +Попытки привязаны ко времени первоначального принятия: + +- сразу; +- через 30 секунд; +- через 5 минут; +- через 25 минут; +- через 1 час. + +Перед каждой попыткой заново читается первый актуальный access-сервер +получателя. После финальной неудачи состояние становится failed. + +Второй сервер отправителя не опрашивается, полная пара ему не передаётся. + +## 5. Состояния + +- accepted — пара сохранена сервером отправителя; +- delivered — входящая копия сохранена единственным сервером получателя; +- failed — финальная попытка завершилась неудачей; +- read-receipt — отдельное подписанное DM-событие. + +Подписанные DM-контейнеры остаются криптографическими фактами. Сетевое +deliveryState остаётся изменяемой серверной метаданной. + +## 6. Удаления + +DeleteMessage и DeleteConversation доставляют подписанный tombstone только на +единственные access-серверы обеих сторон. Повторное применение идемпотентно. + +## 7. Удалённая репликация + +После перехода на один сервер доступа удалены: + +- ReceiveOutcomingMessage; +- DmSyncBatch; +- GetDmDeliveryStatus; +- dm_sync_outbox; +- dm_sync_peer_state; +- ACK-флаги synced; +- периодический DM-backfill между access-серверами. + +Блокчейн-синхронизация через server-PDA sync_servers остаётся отдельным +механизмом и продолжает работать. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 807b5adf..0a22fe0f 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -1,681 +1,205 @@ -# Личные сообщения (DM) — спецификация v1 +# Протокол личных сообщений SHiNE DM v1 -## Статус документа +## 1. Статус документа -Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1. +Это актуальная спецификация логики личных сообщений. Точный байтовый формат +контейнера описан в Формат_DM_v1.md, а расписание доставки — в +Доставка_и_синхронизация_DM.md. -Важно: +Формат SHiNE_DM не изменён переходом на один access-сервер. -- это актуальная реализованная спецификация; -- код и документы по DM должны изменяться синхронно; -- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата. +## 2. Инварианты -Документ фиксирует: +- каждый DM-контейнер подписан clientKey автора; +- сервер проверяет формат, пользователей и подпись до сохранения; +- повторная доставка одной ревизии идемпотентна; +- более старая ревизия не заменяет новую; +- у каждого пользователя действует только access_servers[0]; +- дополнительные элементы старой PDA игнорируются без fallback; +- DM и настройки не реплицируются между access-серверами одного пользователя; +- sync_servers серверного PDA используются только для синхронизации + пользовательских блокчейнов. -- модель DM и смысл типов сообщений; -- правила редактирования, перешифровки и удаления; -- роли существующих API-методов; -- правила межсерверной маршрутизации через `access_servers`; -- общее поведение сервера и БД. +## 3. Типы DM -Точный байтовый формат контейнера вынесен отдельно: +- type=1 — входящая копия сообщения для получателя; +- type=2 — исходящая копия сообщения для отправителя; +- type=3 — входящий read-receipt; +- type=4 — исходящая копия read-receipt; +- type=5 — удаление своего исходящего сообщения; +- type=6 — удаление входящего сообщения получателем; +- type=7 — удаление переписки отправителем; +- type=8 — удаление переписки получателем. -- `docs/Personal_Messages/Формат_DM_v1.md` +Входящая и исходящая копии являются частями криптографического формата, а не +серверной репликацией. Они остаются даже при единственном access-сервере. -Формат клиентских технических вставок внутри plaintext вынесен отдельно: +## 4. Создание сообщения -- `docs/Personal_Messages/Технические_вставки_DM_v1.md` +Клиент: -Устаревшая предыдущая версия сохранена отдельно: +1. получает актуальные clientKey отправителя и получателя; +2. шифрует входящую копию на ключ получателя; +3. шифрует исходящую копию на ключ отправителя; +4. создаёт два связанных контейнера с общим baseKey; +5. подписывает оба контейнера clientKey отправителя; +6. вызывает SendMessagePair на своём access-сервере. -- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` +Сервер отправителя атомарно сохраняет пару. Исходящая копия доступна отправителю, +а входящая хранится для доставки и повторных попыток. -## 1. Основная модель +## 5. Единственный сервер доступа -Личное сообщение в SHiNE хранится как две логические копии одного сообщения: +Пользовательская PDA по-прежнему содержит массив access_servers для +совместимости формата. Действующим считается только первый элемент массива. -- `type=1` — входящее сообщение для получателя; -- `type=2` — исходящая копия сообщения для отправителя. +Routing-проекция user_access_servers_current содержит не более одной строки на +пользователя и строится строго из элемента с ord=1. -Обе копии имеют общий логический идентификатор: +Если первый элемент отсутствует, указывает не на server-PDA или server_address +пуст, маршрут считается отсутствующим. Второй элемент автоматически не +используется. -- `baseKey = fromLogin|toLogin|timeMs|nonce` +UI позволяет: -Идентификатор конкретной копии: +- увидеть текущий сервер; +- заменить его другим сервером; +- записать только один логин в access_servers. -- `messageKey = baseKey|messageType` +Смена сервера не переносит сообщения и настройки. Новый сервер начинает с +пустой локальной истории. -Поля `timeMs` и `nonce` не меняются никогда. +## 6. Доставка сообщения -`nonce` обязателен, потому что одного `timeMs` недостаточно для гарантированной уникальности. +### 6.1. SendMessagePair -## 2. Ключи и шифрование +Официальный UI отправляет пару только через SendMessagePair. -### 2.1. Общий принцип +Сервер: -У пользователя в PDA публикуется один публичный `clientKey` в формате `Ed25519`. +1. проверяет оба контейнера и их связь; +2. сохраняет пару; +3. создаёт delivery-state; +4. до ответа UI выполняет первую попытку доставки; +5. возвращает baseKey, incomingKey, outgoingKey и deliveryState. -Из того же ключевого материала стандартным способом выводится ключ `X25519` для E2EE-шифрования DM. +ReceiveOutcomingMessage удалён. -Отдельный публичный `dmEncKey` в текущей архитектуре не хранится. +### 6.2. ReceiveIncomingMessage -Подробности стандартного преобразования: +Эта операция остаётся внутренней server-to-server операцией. Она нужна, когда +отправитель и получатель используют разные физические серверы. -- `docs/Протоколы/Преобразование_ED25519_в_X25519.md` +Сервер отправителя передаёт только incomingBlobB64 на единственный сервер +получателя. Принимающий сервер: -### 2.2. Правило шифрования копий +- разрешает получателя через свою routing-проекцию; +- заново проверяет пользовательскую подпись; +- применяет входящую копию идемпотентно; +- обновляет диалог и realtime-сессии; +- возвращает успешный ответ только после сохранения. -- `type=1` шифруется на ключ получателя; -- `type=2` шифруется на ключ отправителя. +ReceiveIncomingMessage не реплицирует историю на второй сервер пользователя. -Следствия: +## 7. Повторная доставка -- входящую копию может прочитать только получатель; -- исходящую копию может прочитать только отправитель; -- сервер не должен расшифровывать DM; -- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно. +Состояния: -### 2.3. Что именно знает сервер о `body` +- accepted — пара сохранена сервером отправителя; +- delivered — incoming-копия сохранена сервером получателя; +- failed — закончилась финальная попытка; +- read — подтверждается отдельным type=3/4. -Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob: +Попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час. +Перед каждой попыткой первый маршрут получателя читается заново. -- сервер проверяет внешний контейнер `SHiNE_DM`; -- сервер проверяет подпись; -- сервер проверяет служебные поля envelope; -- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит; -- сервер не обязан разбирать внутренний crypto-контейнер `body`; -- сервер не обязан знать конкретный алгоритм шифрования `body`. +Peer отправителя не получает полную пару и не опрашивается о состоянии. +DmSyncBatch и GetDmDeliveryStatus удалены. -Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`. +## 8. Чтение и realtime -Официальный UI SHiNE при приёме такого сообщения: +GetDirectMessages возвращает историю страницами. UI использует стабильные +messageKey/baseKey для дедупликации и отображает более новую ревизию. -- пытается расшифровать знакомый формат `EncryptedBody_v1_0`; -- показывает текст сообщения при успешной расшифровке; -- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`; -- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`; -- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`. +dm_dialog_state хранит: -После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `` в начале текста. Legacy-вставки `` также продолжают поддерживаться при чтении. -Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope. +- последнюю контентную копию; +- время и ключ последнего сообщения; +- unreadCount; +- watermark прочтения. -### 2.4. Источник истины по пользователю +WebSocket push ускоряет отображение, но после переподключения клиент запрашивает +историю у своего единственного access-сервера. -Для DM-проверки сервер использует: +## 9. Подтверждения прочтения -- локальную runtime-проекцию пользователей как кэш; -- Solana PDA как источник истины по `clientKey` и `access_servers`. +Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же +маршрут SendMessagePair → ReceiveIncomingMessage. -Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA: +Сервер хранит наибольший известный watermark, поэтому переставленные или +повторные receipts не уменьшают состояние прочтения. -- в `GetUser`; -- при серверной верификации DM-подписи; -- перед межсерверной маршрутизацией через `access_servers`. +## 10. Редактирование -## 3. Типы сообщений +Редактирование сохраняет логический messageKey исходной копии и увеличивает +revisionTimeMs. Сервер принимает только более новую валидную ревизию. -В версии v1 используются следующие `messageType`: +Если доставка исходного сообщения ещё не завершилась, следующая попытка +использует актуальную сохранённую incoming-копию. -- `1` — входящее сообщение; -- `2` — исходящая копия сообщения; -- `3` — входящее подтверждение прочтения; -- `4` — исходящая копия подтверждения прочтения; -- `5` — сообщение удалено отправителем; -- `6` — сообщение удалено получателем; -- `7` — переписка удалена отправителем; -- `8` — переписка удалена получателем. +## 11. Удаление -Типы `1/2` — это обычные контентные DM, в том числе их последующие ревизии. +DeleteMessage принимает type=5/6. DeleteConversation принимает type=7/8. -Типы `3/4` — служебные подтверждения прочтения. +Удаление является подписанным tombstone. Оно: -Типы `5/6/7/8` — служебные события удаления без зашифрованного тела. +- проверяется как обычный DM-контейнер; +- применяется идемпотентно; +- удаляет или блокирует соответствующий локальный контент; +- доставляется только на единственные access-серверы обеих сторон. -## 4. Базовые операции +Если сервер уже знает более новый tombstone, старая ревизия контента не +восстанавливает удалённую историю. -### 4.1. Создание нового сообщения +## 12. Хранение -Новое сообщение отправляется парой блоков через: +Основные таблицы: -- `SendMessagePair` -- `ReceiveOutcomingMessage` как алиас +- signed_messages — подписанные DM-контейнеры; +- dm_dialog_state — материализованный список диалогов; +- dm_delivery_state — изменяемое состояние сетевой доставки; +- user_access_servers_current — единственный действующий маршрут пользователя. -Пару создаёт автор сообщения. +Удалены: -Пара содержит: +- dm_sync_outbox; +- dm_sync_peer_state; +- user_settings_sync_peer_state; +- synced-флаги репликации. -- одну входящую копию `type=1`; -- одну исходящую копию `type=2`; -- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`; -- одинаковый логический plaintext; -- разные ciphertext для разных владельцев копий. +## 13. Границы текущей версии -### 4.2. Редактирование сообщения +- межсерверная авторизация ServerHello пока не подписана; +- sourceServerLogin не является криптографическим доказательством сервера; +- пользовательская подпись каждого SHiNE_DM проверяется независимо от + транспорта; +- автоматической миграции истории при смене access-сервера нет; +- резервного fallback-сервера нет. -Редактирование общего текста делает автор сообщения. +## 14. Сохранённые механизмы -При редактировании: +Переход на один access-сервер не затрагивает: -- `baseKey` остаётся тем же; -- `messageType` остаётся тем же; -- `revisionTimeMs` увеличивается; -- автор заново шифрует обе копии и снова отправляет пару через `SendMessagePair`. +- байтовый формат SHiNE_DM; +- шифрование входящей и исходящей копий; +- пользовательские подписи; +- deliveryState и retry-воркер; +- read-receipts; +- синхронизацию пользовательских блокчейнов через server-PDA sync_servers; +- общий постоянный WSS ServerConnectionPool. -Если `revisionTimeMs = 0`, это исходная версия. - -Если `revisionTimeMs > 0`, это новая ревизия сообщения. - -### 4.3. Перешифровка - -Перешифровка нужна для будущего сценария полной или частичной перепаковки истории сообщений при сохранении тех же логических идентификаторов сообщений. - -При перешифровке: - -- `baseKey` остаётся прежним; -- `messageKey` остаётся прежним; -- plaintext может остаться тем же; -- ciphertext меняется; -- `reencryptedAtMs` получает ненулевое значение; -- `revisionTimeMs` может остаться прежним, в том числе нулевым, если менялось только шифрование без редактирования текста; -- сервер при выборе актуальной версии обязан учитывать обе метки времени. - -Если сообщение уже удалено одним из типов `5/6`, его перешифровывать больше нельзя. - -Если переписка уже удалена типом `7/8`, более старые сообщения этой пары тоже перешифровывать нельзя. - -### 4.4. Приём входящей копии - -Для server-to-server доставки одной входящей копии используется: - -- `ReceiveIncomingMessage` - -Этот метод должен принимать: - -- новые входящие сообщения; -- входящие обновления/редактирования; -- будущие входящие перешифрованные копии. - -## 5. Удаление одного сообщения - -### 5.1. Общая логика - -Удаление одного сообщения в v1 всегда глобальное у обеих сторон. - -Локального удаления только у себя в этой версии протокола не вводится. - -### 5.2. Типы удаления - -- `type=5` — сообщение удалено отправителем; -- `type=6` — сообщение удалено получателем. - -Удаляющее сообщение: - -- не содержит зашифрованного тела; -- хранится в БД как tombstone; -- терминально закрывает это сообщение; -- не даёт больше принять никакую более позднюю содержательную версию этого же `messageKey`. - -### 5.3. Метод удаления - -Для удаления одного сообщения нужен отдельный метод, условно: - -- `DeleteMessage` - -Если сервер впервые получает валидное удаляющее сообщение: - -- сохраняет tombstone в БД; -- удаляет или замещает прежнюю версию сообщения tombstone-записью; -- распространяет это же удаление на серверы доступа обеих сторон; -- не принимает в будущем попытки "оживить" это сообщение. - -## 6. Удаление всей переписки - -### 6.1. Отдельное служебное сообщение - -Удаление всей переписки между двумя пользователями — это отдельное служебное сообщение без ciphertext. - -Типы: - -- `type=7` — переписка удалена отправителем; -- `type=8` — переписка удалена получателем. - -### 6.2. Граница удаления - -Границей удаления считается: - -- `timeMs` самого служебного сообщения удаления переписки - -Отдельное `deleteBeforeTimeMs` в этой версии не вводится. - -### 6.3. Правило применения - -Если сервер получает такое сообщение впервые: - -- сохраняет его в БД как tombstone переписки; -- удаляет из БД все сообщения этой пары пользователей с `timeMs` меньше времени служебного сообщения; -- больше не принимает новые или повторно доставленные сообщения с `timeMs` раньше этой границы; -- распространяет это же сообщение удаления переписки на серверы доступа обеих сторон. - -### 6.4. Поведение при позднем старом сообщении - -Если после удаления переписки приходит старое сообщение, у которого: - -- `timeMs < deleteConversationMessage.timeMs` - -то сервер: - -- не принимает это сообщение; -- распространяет уже известный tombstone удаления переписки на серверы доступа обеих сторон; -- ожидает, что вторая сторона обработает его как обычное уже известное удаление переписки. - -### 6.5. Будущие новые сообщения - -После удаления всей переписки новые сообщения между этими же пользователями разрешены, если: - -- их `timeMs` больше времени служебного сообщения удаления переписки. - -## 7. Серверы и маршрутизация - -### 7.1. `access_servers` - -Для обычного пользователя список серверов доставки и доступа задаётся через: - -- `access_servers` - -Это: - -- сервера доступа пользователя; -- сервера relay; -- сервера, через которые пользователь получает личные сообщения и другие пользовательские операции. - -### 7.2. `sync_servers` - -`sync_servers` относятся не к обычной пользовательской маршрутизации DM, а к server-to-server партнёрству серверного узла. - -Они используются для: - -- синхронизации серверных данных; -- синхронизации пользовательских блокчейнов SHiNE; -- общей межсерверной координации. - -`sync_servers` не являются списком пользовательских серверов доставки DM. - -### 7.3. Несколько серверов у отправителя и получателя - -Протокол должен поддерживать ситуацию, когда: - -- у отправителя несколько `access_servers`; -- у получателя несколько `access_servers`; -- часть серверов у сторон совпадает; -- часть серверов уникальна. - -Из этого следуют требования: - -- все DM-операции должны быть идемпотентны; -- повторное получение уже известного события не должно ломать состояние; -- дубль tombstone должен быть безопасен; -- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone. - -## 8. Методы и их роли - -### 8.1. Существующие методы, которые сохраняются - -- `SendMessagePair` -- `ReceiveOutcomingMessage` -- `ReceiveIncomingMessage` -- `DeleteMessage` -- `DeleteConversation` - -Их роли в v1: - -- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором; -- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt; -- `ReceiveOutcomingMessage` — алиас `SendMessagePair`. - -### 8.2. Новые методы, которые нужны - -### 8.3. Серверный слой диалогов - -Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов: - -- отдельная запись на пару `owner_login` + `peer_login`; -- `relation_flag` со значениями `close_friend`, `contact`, `none`; -- `last_message_blob_b64` как последний контентный signed DM block в base64; -- `last_message_time_ms`; -- `unread_count`; -- `last_read_receipt_time_ms` как watermark последнего подтверждения прочтения. - -Ключевые правила: - -- `close_friend` всегда имеет приоритет над `contact`; -- если `read receipt` приходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад; -- `unread_count` пересчитывается сервером по сообщениям диалога с учётом watermark и `read_at_ms`; -- старые исторические данные восстанавливаются из `signed_messages` при инициализации/миграции; -- UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов. - -## 9. Правила валидации и применения - -### 9.1. Общее правило по ревизиям - -Для одного и того же контентного сообщения сервер сравнивает пару: - -- `revisionTimeMs`; -- `reencryptedAtMs`. - -Правило: - -- если новый `revisionTimeMs` больше сохранённого, сообщение применяется; -- если `revisionTimeMs` равен, но новый `reencryptedAtMs` больше сохранённого, сообщение применяется; -- если обе величины равны, сообщение не применяется; -- если новая пара (`revisionTimeMs`, `reencryptedAtMs`) меньше или равна сохранённой, сообщение не применяется. - -Содержимое `body` при этом сравнении не участвует. - -То есть если сервер уже видел ту же пару (`revisionTimeMs`, `reencryptedAtMs`), он считает, что такая версия у него уже есть. - -### 9.2. Повторный tombstone - -Повторный tombstone означает ситуацию, когда сервер повторно получает то же самое событие удаления: - -- того же сообщения; -- или той же переписки. - -Это может произойти из-за: - -- повторной межсерверной доставки; -- нескольких `access_servers`; -- сетевых retry; -- дублирующей пересылки с разных маршрутов. - -Правило: - -- повторный tombstone должен быть полностью безопасен; -- если соответствующее удаление уже сохранено, сервер ничего не меняет и просто игнорирует повтор. - -### 9.3. Сообщения старше границы удалённой переписки - -Если для пары пользователей уже есть сохранённая граница удаления переписки, и приходит: - -- обычное сообщение; -- удаление одного сообщения; -- повторное удаление переписки; -- любое другое DM-событие; - -у которого `timeMs` меньше этой границы, сервер: - -- ничего не меняет в БД; -- не восстанавливает старую историю; -- не применяет это событие повторно. - -Если это старое контентное сообщение или входящая копия, сервер дополнительно перерассылает уже известный tombstone удаления переписки на `access_servers` обеих сторон, чтобы отстающие серверы сами подчистили историю. - -Такие сообщения считаются частью уже удалённой истории. - -### 9.4. Приоритет удаления переписки - -Если сначала пришло удаление переписки, а потом удаление одного старого сообщения из этой переписки, сервер должен: - -- проигнорировать это удаление одного сообщения; -- не создавать новых изменений поверх уже удалённой истории. - -То же правило действует и для обычных сообщений, и для редактирований старых сообщений. - -## 10. JSON API v1 - -### 10.1. `SendMessagePair` - -Назначение: - -- клиент отправляет новый DM; -- клиент отправляет редактирование старого DM; -- клиент отправляет парную новую ревизию типов `1/2`. - -Request: - -```json -{ - "op": "SendMessagePair", - "requestId": "req-123", - "payload": { - "incomingBlobB64": "...", - "outgoingBlobB64": "..." - } -} -``` - -Правила: - -- клиенту достаточно отправить пару на один любой доступный сервер; -- сервер после принятия сам отвечает за дальнейшую межсерверную доставку. - -### 10.2. `ReceiveIncomingMessage` - -Назначение: - -- приём одной входящей копии по схеме server-to-server; -- приём входящего редактирования; -- приём входящего read-receipt. - -Request: - -```json -{ - "op": "ReceiveIncomingMessage", - "requestId": "req-456", - "payload": { - "incomingBlobB64": "...", - "sourceServerLogin": "server-a" - } -} -``` - -`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику. - -### 10.3. `DeleteMessage` - -Назначение: - -- удалить одно сообщение у обеих сторон. - -Request: - -```json -{ - "op": "DeleteMessage", - "requestId": "req-789", - "payload": { - "blobB64": "..." - } -} -``` - -Ожидается контейнер типа: - -- `5`, если удаление инициировал отправитель; -- `6`, если удаление инициировал получатель. - -UI-следствие для клиента: - -- пользователь может удалить как своё исходящее сообщение, так и входящее; -- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`). - -### 10.4. `DeleteConversation` - -Назначение: - -- удалить всю переписку до времени самого служебного сообщения. - -Request: - -```json -{ - "op": "DeleteConversation", - "requestId": "req-790", - "payload": { - "blobB64": "..." - } -} -``` - -Ожидается контейнер типа: - -- `7`, если удаление инициировал отправитель; -- `8`, если удаление инициировал получатель. - -UI-следствие для клиента: - -- действие `Очистить историю` должно отправлять служебное сообщение удаления переписки; -- для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (`type=8`); -- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории; -- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`. - -### 10.5. `GetDirectMessages` - -Назначение: - -- постранично отдавать историю одного личного диалога; -- не вываливать весь старый backlog автоматически в момент логина; -- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков. - -Правила: - -- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа; -- клиент сам запрашивает первую страницу истории у нужного собеседника; -- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`; -- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`. - -Содержимое страницы: - -- сервер возвращает только контентные DM типов `1/2`; -- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются; -- сообщения идут страницами от новых к старым; -- лимит страницы считается по самим chat-сообщениям диалога. - -Поле прочтения: - -- для каждого контентного сообщения сервер может вернуть `readAtMs`; -- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу; -- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени; -- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается. - -## 11. Межсерверная доставка - -### 11.1. Клиентская сторона - -Клиенту достаточно отправить сообщение на: - -- любой один доступный сервер. - -### 11.2. Серверная сторона - -После принятия валидного события сервер должен отправлять его: - -- на все серверы из `access_servers` отправителя; -- на все серверы из `access_servers` получателя. - -Если часть серверов совпадает, это допустимо. - -Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии. - -Идемпотентность обязательна. - -### 11.3. Догоняющая синхронизация истории - -Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция: - -- `DmSyncBatch` - -Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору: - -- `ownerLogin`; -- `afterStoredAtMs`; -- `afterMessageKey`; -- `limit`, максимум `500`; -- `maxBytes`, ограничение суммарного размера raw-блоков пачки. - -Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю: - -- контентные копии и read-receipt по `target_login`; -- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`. - -Порядок пачки: - -- `created_at_ms ASC`; -- `message_key ASC`. - -Курсор хранится локально для пары: - -- пользователь; -- удалённый access-сервер. - -При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками. - -При применении событий, полученных через `DmSyncBatch`, сервер: - -- проверяет формат `SHiNE_DM`; -- проверяет подпись; -- применяет существующие правила ревизий, read-receipt и tombstone; -- не отправляет realtime/push-уведомления клиентам; -- не запускает повторный fan-out, чтобы не создавать циклы. - -Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов. - -В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`. - -### 11.4. Ошибки доставки - -Если часть серверов временно недоступна: - -- это не должно отменять локальное принятие уже валидного сообщения; -- повторная доставка может делаться отдельным retry-механизмом; -- повторное получение того же события должно быть безопасным. - -## 12. Хранение в БД - -Основная таблица остаётся: - -- `signed_messages` - -В ней должны сохраняться: - -- обычные контентные DM; -- read-receipt DM; -- tombstone одного сообщения; -- tombstone удаления переписки. - -Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие. - -Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы: - -- защищать от повторного приёма старых версий; -- не терять факт удаления; -- корректно синхронизировать событие между серверами. - -Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. - -Для догоняющей межсерверной синхронизации дополнительно используются: - -- индекс по `target_login`, `created_at_ms`, `message_key`; -- отдельные индексы по delete-событиям для `from_login` и `to_login`; -- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`. - -## 13. Что обязательно должно измениться в коде относительно v0.5 - -- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`; -- сервер не должен трактовать `encryptedBody` как обычный UTF-8 текст; -- сервер не должен валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`; -- DM должны реально шифроваться end-to-end; -- удаление одного сообщения должно стать терминальным tombstone; -- удаление всей переписки должно стать отдельным служебным событием; -- межсерверная маршрутизация DM должна идти через `access_servers`; -- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM; -- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает; -- логика должна быть безопасна для нескольких серверов у каждой стороны. - -## 14. Что в v1 пока не входит - -- вложения в DM; -- хранение отдельного `keyId` шифрования в DM; -- ротация `clientKey`; -- финальная конкретная UI-реализация массовой перешифровки; -- межсерверная авторизация `DmSyncBatch`. +## UI списка личных чатов — фильтры и отображение времени (2026-08-28) +- Заголовок «Чаты» является переключателем фильтра списка. +- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые». +- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются. +- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index b2bdaf10..5c870d04 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -13,6 +13,9 @@ Логика протокола, API и поведение сервера описаны отдельно: - `docs/Personal_Messages/Протокол_DM_v1.md` +- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` + +`deliveryState`, retry-времена и sync-флаг не входят в `SHiNE_DM` и не подписываются пользователем. Это изменяемые серверные метаданные, хранящиеся отдельно от raw-контейнера. Для корреляции доставки используется уже существующий `messageKey`; добавление delivery-воркера не меняет ни одного байта формата ниже. ## 1. Общие правила @@ -337,4 +340,19 @@ ReadReceiptBody_v1_0 В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. -Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`. +`SendMessagePair` передаёт входящую и исходящую подписанные копии от UI на +сервер отправителя. Внутренняя операция `ReceiveIncomingMessage` передаёт +только входящую raw-копию в Base64 на единственный access-сервер получателя. +Обе операции используют существующий байтовый формат `SHiNE_DM`. +Межсерверные `ReceiveOutcomingMessage` и `DmSyncBatch` удалены. +Принимающий сервер заново проверяет пользовательскую подпись и применяет +контейнер по его `messageType`. + +## 14. Превью в списке диалогов + +Выбор последнего сообщения для клиентского превью не является частью байтового формата. Клиент сравнивает время сообщений обоих направлений и показывает текст самого нового входящего или исходящего сообщения. Локальная расшифрованная копия может временно иметь приоритет над более старым `lastMessageBlobB64` из серверной сводки. + +Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются. + +## Примечание UI списка чатов (2026-08-28) +Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет. diff --git a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md index 6d432b78..f1020abf 100644 --- a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md +++ b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md @@ -20,8 +20,10 @@ - сервер запускает sync-модуль до продолжения собственного startup; - server startup ждёт входа модуля в состояние `READY`; - актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`. -- для быстрого локального маршрутизационного lookup серверов доступа поддерживается вторичная проекция - `user_access_servers_current`, которая автоматически пересобирается из `solana_user_pda_current`. +- для быстрого локального lookup единственного сервера доступа поддерживается + вторичная проекция `user_access_servers_current`, которая автоматически + пересобирается только из первого элемента `access_servers[0]` в + `solana_user_pda_current`. --- diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index 32440859..877a827f 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -291,8 +291,13 @@ AccessServersBlock - блок может отсутствовать, если серверы доступа не заданы; - список может обновляться при изменении маршрутизации пользователя; -- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; -- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. +- `access_servers` — массив логинов серверов доступа/relay. Формат массива + сохранён для совместимости, но текущая клиентская и серверная логика использует + только первый элемент `access_servers[0]`; +- остальные элементы игнорируются без fallback; +- официальный UI записывает ровно один сервер и позволяет только заменить его; +- Solana-программа не обязана проверять, что логин действительно зарегистрирован + как сервер. ## 14. SessionsBlock diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index e268bc28..aa107cf8 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -74,7 +74,8 @@ - `server_key: Pubkey` - `server_address: String` - `sync_servers: Vec` -- `access_servers: Vec` +- `access_servers: Vec` — формат остаётся массивом, но runtime + использует только первый элемент - `trusted_count: u8` ## Главные PDA diff --git a/shine-UI/assets/SHiNE_connections_blue.svg b/shine-UI/assets/SHiNE_connections_blue.svg new file mode 100644 index 00000000..7d9f65fb --- /dev/null +++ b/shine-UI/assets/SHiNE_connections_blue.svg @@ -0,0 +1,23 @@ + + SHiNE connections + Blue six-part connections symbol on a transparent background + + + + + + + + + + + + + + + + + + + + diff --git a/shine-UI/assets/profile-icon-profile.svg b/shine-UI/assets/profile-icon-profile.svg index 5d7244e4..f507bc3a 100644 --- a/shine-UI/assets/profile-icon-profile.svg +++ b/shine-UI/assets/profile-icon-profile.svg @@ -1,4 +1,4 @@ - - + + diff --git a/shine-UI/assets/profile-icon-settings.svg b/shine-UI/assets/profile-icon-settings.svg index c3bd6471..2419d0b9 100644 --- a/shine-UI/assets/profile-icon-settings.svg +++ b/shine-UI/assets/profile-icon-settings.svg @@ -1,5 +1,4 @@ - - - + + diff --git a/shine-UI/assets/profile-icon-wallet.svg b/shine-UI/assets/profile-icon-wallet.svg index 03cccb21..fc9b38a1 100644 --- a/shine-UI/assets/profile-icon-wallet.svg +++ b/shine-UI/assets/profile-icon-wallet.svg @@ -1,6 +1,5 @@ - - - - + + + diff --git a/shine-UI/index.html b/shine-UI/index.html index 6a0bf3a9..ed3ca249 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -12,7 +12,7 @@ СИЯНИЕ