From d8e0c77951e1389aab7d463514d9a7e8ed92103eb08aa459afb3aa41e245a133 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 24 Aug 2026 17:50:36 +0400 Subject: [PATCH 01/15] =?UTF-8?q?=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA=20DM-=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/shine/db/DatabaseInitializer.java | 12 + .../java/shine/db/dao/DmDeliveryStateDAO.java | 518 ++++++++++++++++++ .../java/shine/db/dao/DmSyncOutboxDAO.java | 232 ++++++++ .../db/entities/DmDeliveryStateEntry.java | 84 +++ .../shine/db/entities/DmSyncOutboxEntry.java | 26 + .../main/resources/postgres/migration_v12.sql | 139 +++++ .../main/resources/postgres/migration_v13.sql | 46 ++ .../src/main/resources/postgres/schema_v1.sql | 47 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../JSON/messages/DmDeliveryIds.java | 12 + .../JSON/messages/DmDeliveryRealtime.java | 25 + .../JSON/messages/DmSyncApplySupport.java | 58 ++ .../Net_DeleteConversation_Handler.java | 15 + .../messages/Net_DeleteMessage_Handler.java | 15 + .../messages/Net_DmSyncBatch_Handler.java | 64 ++- .../Net_GetDirectMessages_Handler.java | 13 + .../Net_GetDmDeliveryStatus_Handler.java | 35 ++ ...t_MarkAllUserSettingsUnsynced_Handler.java | 7 + .../Net_ReceiveIncomingMessage_Handler.java | 28 +- .../messages/Net_SendMessagePair_Handler.java | 62 ++- .../entyties/Net_DmSyncBatch_Request.java | 8 + .../entyties/Net_DmSyncBatch_Response.java | 35 +- .../Net_GetDirectMessages_Response.java | 3 + .../Net_GetDmDeliveryStatus_Request.java | 10 + .../Net_GetDmDeliveryStatus_Response.java | 16 + ..._MarkAllUserSettingsUnsynced_Response.java | 3 + .../Net_SendMessagePair_Response.java | 3 + .../server/sync/DmDeliveryCoordinator.java | 309 +++++++++++ .../java/server/sync/DmSyncWakeSignal.java | 17 + .../java/server/sync/RemoteDmSyncClient.java | 126 +++-- .../java/server/sync/RemoteSyncSession.java | 70 +++ .../sync/RemoteUserSettingsSyncClient.java | 23 +- .../server/sync/PeriodicDmSyncService.java | 176 ++---- .../sync/PeriodicUserSettingsSyncService.java | 62 ++- .../src/main/resources/application.properties | 10 +- docs/API/09_Operations_Index.md | 8 +- docs/API/12_Direct_Messages_Push_Calls_API.md | 145 +++-- docs/API/13_User_Settings_API.md | 6 +- docs/Blockchain/sync-between-servers.md | 44 +- docs/Personal_Messages/README.md | 1 + .../Доставка_и_синхронизация_DM.md | 136 +++++ docs/Personal_Messages/Протокол_DM_v1.md | 98 ++-- docs/Personal_Messages/Формат_DM_v1.md | 5 +- shine-UI/js/app.js | 12 + shine-UI/js/pages/chat-view.js | 49 +- shine-UI/js/pages/messages-list.js | 2 + shine-UI/js/state.js | 38 +- shine-UI/styles/components.css | 24 +- 48 files changed, 2498 insertions(+), 383 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDeliveryStateDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncOutboxDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmDeliveryStateEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncOutboxEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v12.sql create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v13.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryIds.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmDeliveryRealtime.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDmDeliveryStatus_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDmDeliveryStatus_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmSyncWakeSignal.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteSyncSession.java create mode 100644 docs/Personal_Messages/Доставка_и_синхронизация_DM.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..ee5e0430 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,8 @@ 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 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 +41,8 @@ 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"; private DatabaseInitializer() {} @@ -142,6 +146,14 @@ 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; + } } } 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/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/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/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 488e3d09..102e88f3 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, 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; @@ -808,6 +808,51 @@ CREATE TABLE IF NOT EXISTS dm_sync_peer_state ( CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner ON dm_sync_peer_state(owner_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_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); + +-- У одного пользователя теперь максимум один второй access-сервер, поэтому +-- достаточно одного флага ACK на событие. Курсор по времени больше не нужен. +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; + CREATE TABLE IF NOT EXISTS message_views_state ( viewer_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, 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..336aaf5f 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 @@ -98,6 +98,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_GetDmDeliveryStatus_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; @@ -112,6 +113,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_GetDmDeliveryStatus_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; @@ -220,6 +222,7 @@ public final class JsonHandlerRegistry { Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()), + Map.entry("GetDmDeliveryStatus", new Net_GetDmDeliveryStatus_Handler()), Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()), Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()), Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()), @@ -310,6 +313,7 @@ public final class JsonHandlerRegistry { Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class), + Map.entry("GetDmDeliveryStatus", Net_GetDmDeliveryStatus_Request.class), Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class), Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class), Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class), 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 index 37a1f0e0..92536de9 100644 --- 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 @@ -1,7 +1,13 @@ package server.logic.ws_protocol.JSON.messages; import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.DmSyncOutboxDAO; +import shine.db.dao.DmDeliveryStateDAO; +import shine.db.entities.DmDeliveryStateEntry; import shine.db.entities.SignedMessageEntry; +import server.sync.DmDeliveryCoordinator; + +import java.util.List; public final class DmSyncApplySupport { private DmSyncApplySupport() {} @@ -47,4 +53,56 @@ public final class DmSyncApplySupport { int messageType, SignedMessagesDAO.ApplyStatus status ) {} + + public static void applySyncedItem( + String ownerLogin, String eventId, List blobsB64 + ) throws Exception { + if (blobsB64 == null || blobsB64.isEmpty() || blobsB64.size() > 2) { + throw new IllegalArgumentException("BAD_BLOB_COUNT"); + } + if (blobsB64.size() == 1) { + ApplyResult result = applySyncedBlob(ownerLogin, blobsB64.get(0)); + SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(result.messageKey()); + long createdAt = stored == null ? System.currentTimeMillis() : stored.getCreatedAtMs(); + DmSyncOutboxDAO.getInstance().upsert( + ownerLogin, result.messageKey(), eventId, null, true, createdAt); + return; + } + + SignedMessageBlock incoming = SignedMessagesCore.parseFromB64(blobsB64.get(0)); + SignedMessageBlock outgoing = SignedMessagesCore.parseFromB64(blobsB64.get(1)); + SignedMessagesCore.validatePair(incoming, outgoing); + SignedMessagesCore.verifyUsersAndSignature(incoming); + SignedMessagesCore.verifyUsersAndSignature(outgoing); + if (!outgoing.fromLogin.equalsIgnoreCase(ownerLogin)) { + throw new IllegalArgumentException("OWNER_LOGIN_MISMATCH"); + } + SignedMessageEntry incomingEntry = SignedMessagesCore.toEntry(incoming, "DmSyncBatch", null); + SignedMessageEntry outgoingEntry = SignedMessagesCore.toEntry(outgoing, "DmSyncBatch", null); + if (incoming.isContentType()) { + SignedMessagesDAO.getInstance().upsertContentPair(incomingEntry, outgoingEntry); + } else { + SignedMessagesDAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + } + DmSyncOutboxDAO.getInstance().upsert( + ownerLogin, outgoingEntry.getMessageKey(), eventId, + incomingEntry.getMessageKey(), true, outgoingEntry.getCreatedAtMs()); + + long now = System.currentTimeMillis(); + long signedAt = Math.max(outgoing.timeMs, + Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs)); + long acceptedAt = signedAt > 0L ? Math.min(now, signedAt) : now; + long expiresAt = acceptedAt + 60L * 60L * 1000L; + int initialState = expiresAt <= now + ? DmDeliveryStateEntry.FAILED_FINAL + : DmDeliveryStateEntry.PENDING_NONE; + DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair( + outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(), + outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(), + acceptedAt, expiresAt, initialState, null, null, + initialState == DmDeliveryStateEntry.PENDING_NONE); + if (delivery != null && initialState == DmDeliveryStateEntry.PENDING_NONE) { + DmDeliveryCoordinator.assistReceivedPairAsync(eventId); + } + } } 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..dbb7c499 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,8 @@ 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.dao.DmSyncOutboxDAO; import shine.db.entities.SignedMessageEntry; public class Net_DeleteConversation_Handler implements JsonMessageHandler { @@ -43,6 +45,8 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { + DmDeliveryStateDAO.getInstance().removeMissingMessages(); + recordOutboxForLocalOwners(entry); counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { @@ -63,4 +67,15 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + + private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception { + String eventId = DmDeliveryIds.forEntry(entry); + if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) { + DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); + } + if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin()) + && server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) { + DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); + } + } } 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..affd543d 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,8 @@ 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.dao.DmSyncOutboxDAO; import shine.db.entities.SignedMessageEntry; public class Net_DeleteMessage_Handler implements JsonMessageHandler { @@ -43,6 +45,8 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { + DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey()); + recordOutboxForLocalOwners(entry); counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { @@ -63,4 +67,15 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + + private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception { + String eventId = DmDeliveryIds.forEntry(entry); + if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) { + DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); + } + if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin()) + && server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) { + DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); + } + } } 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 index f05cc99a..5ba82ff0 100644 --- 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 @@ -12,6 +12,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.DbController; import shine.db.dao.UserSettingsDAO; +import shine.db.dao.DmSyncOutboxDAO; +import server.sync.DmSyncWakeSignal; import java.sql.Connection; @@ -23,16 +25,21 @@ public class Net_MarkAllUserSettingsUnsynced_Handler implements JsonMessageHandl Net_MarkAllUserSettingsUnsynced_Request req = (Net_MarkAllUserSettingsUnsynced_Request) baseRequest; try (Connection c = DbController.getInstance().getConnection()) { int updated; + int dmUpdated; if (req.getLogin() == null || req.getLogin().isBlank()) { updated = UserSettingsDAO.getInstance().markAllUnsynced(c); + dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced(); } else { updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim()); + dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced(req.getLogin().trim()); } + DmSyncWakeSignal.request(); Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); resp.setStatus(WireCodes.Status.OK); resp.setUpdated(updated); + resp.setDmUpdated(dmUpdated); return resp; } catch (Exception e) { log.error("MarkAllUserSettingsUnsynced failed", e); 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..13f7e9ad 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,12 @@ 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.DmSyncOutboxDAO; 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 +43,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 +60,27 @@ 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", "На сервере уже есть более новая ревизия сообщения"); + } + boolean receivedFromRecipientPeer = DmDeliveryCoordinator.sourceIsOtherAccessServer( + incoming.toLogin, req.getSourceServerLogin()); + DmSyncOutboxDAO.getInstance().upsert( + incoming.toLogin, + entry.getMessageKey(), + DmDeliveryIds.forEntry(entry), + null, + receivedFromRecipientPeer, + entry.getCreatedAtMs() + ); + 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..609dba1d 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,15 @@ 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.DmSyncOutboxDAO; 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 { @@ -43,8 +48,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessageEntry incomingEntry; SignedMessageEntry outgoingEntry; + boolean fromPeer = "ReceiveOutcomingMessage".equalsIgnoreCase(req.getOp()) + || !isBlank(req.getSourceServerLogin()); try { - String sourceApi = "SendMessagePair"; + String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId); outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId); @@ -79,15 +86,53 @@ 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; + long signedAtMs = Math.max(outgoing.timeMs, + Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs)); + acceptedAtMs = fromPeer && signedAtMs > 0L ? Math.min(nowMs, signedAtMs) : 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, fromPeer && initialState == DmDeliveryStateEntry.PENDING_NONE + ); + + DmSyncOutboxDAO.getInstance().upsert( + outgoingEntry.getFromLogin(), outgoingEntry.getMessageKey(), eventId, + incomingEntry.getMessageKey(), fromPeer, acceptedAtMs); + + // Если этот же сервер также обслуживает получателя, его входящая копия + // имеет отдельный sync-флаг владельца-получателя. + if (DmDeliveryCoordinator.isLocalAccessServer(incomingEntry.getToLogin())) { + boolean incomingAlreadySynced = DmDeliveryCoordinator.sourceIsOtherAccessServer( + incomingEntry.getToLogin(), req.getSourceServerLogin()); + DmSyncOutboxDAO.getInstance().upsert( + incomingEntry.getToLogin(), incomingEntry.getMessageKey(), DmDeliveryIds.forEntry(incomingEntry), + null, incomingAlreadySynced, acceptedAtMs); + } + + if (delivery != null && pairStatus.applied()) { + if (fromPeer) { + DmDeliveryCoordinator.assistReceivedPairAsync(delivery.getEventId()); + } else { + // Первая доставка выполняется до ответа клиенту. Два сервера + // получателя вызываются параллельно внутри координатора. + DmDeliveryCoordinator.processDueEntry(delivery); + delivery = DmDeliveryStateDAO.getInstance() + .getByOutgoingMessageKey(outgoingEntry.getMessageKey()); + } } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); @@ -99,6 +144,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/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_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java index 0b5c7f8d..e843327c 100644 --- 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 @@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; public class Net_MarkAllUserSettingsUnsynced_Response extends Net_Response { private Integer updated; + private Integer dmUpdated; public Integer getUpdated() { return updated; } public void setUpdated(Integer updated) { this.updated = updated; } + public Integer getDmUpdated() { return dmUpdated; } + public void setDmUpdated(Integer dmUpdated) { this.dmUpdated = dmUpdated; } } 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/sync/DmDeliveryCoordinator.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java new file mode 100644 index 00000000..4b3223e0 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java @@ -0,0 +1,309 @@ +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.DmSyncOutboxDAO; +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.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ThreadFactory; + +/** + * Координатор доставки исходящей DM-пары. Первая попытка выполняется до ответа + * клиенту, последующие — пятисекундным воркером. + */ +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 RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + private static final DmDeliveryStateDAO DELIVERY_DAO = DmDeliveryStateDAO.getInstance(); + private static final DmSyncOutboxDAO OUTBOX_DAO = DmSyncOutboxDAO.getInstance(); + private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance(); + private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); + private static final ExecutorService ASSIST_EXECUTOR = new ThreadPoolExecutor( + 2, 2, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(500), + daemonThreadFactory("dm-peer-assist"), + new ThreadPoolExecutor.DiscardPolicy()); + private static final ExecutorService RECIPIENT_EXECUTOR = new ThreadPoolExecutor( + 4, 16, 60L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(1000), + daemonThreadFactory("dm-recipient-delivery"), + new ThreadPoolExecutor.CallerRunsPolicy()); + + 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) { + processDueEntry(snapshot, true); + } + + private static void processDueEntry(DmDeliveryStateEntry snapshot, boolean allowInitialHandoff) { + 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(); + + // Перед попытками на 5-й, 25-й и 60-й минутах сначала спрашиваем + // второй сервер отправителя: возможно, он уже доставил сообщение. + if (current.getDeliveryState() == DmDeliveryStateEntry.ACCEPTED + && (snapshot.getAttemptIndex() >= 2 || finalAttempt)) { + current = acceptPeerDeliveryStatus(current); + if (current != null && current.isDelivered()) { + DmDeliveryRealtime.notifySender(current); + return; + } + } + + DmDeliveryStateEntry afterAttempt = attemptRecipientRoutes( + current, finalAttempt ? null : nextAttemptAtMs); + + // После первой попытки сразу передаём полную пару второму серверу + // отправителя старой операцией ReceiveOutcomingMessage. + if (allowInitialHandoff && snapshot.getAttemptIndex() == 0 && afterAttempt != null) { + afterAttempt = handoffPairToSenderPeer(afterAttempt); + } + + 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); + } + } + + public static void assistReceivedPairAsync(String eventId) { + if (eventId == null || eventId.isBlank()) return; + ASSIST_EXECUTOR.execute(() -> { + try { + DmDeliveryStateEntry row = DELIVERY_DAO.getByEventId(eventId); + if (row != null) processDueEntry(row, false); + } catch (Exception e) { + log.warn("DM peer assist failed: eventId={}", eventId, e); + } + }); + } + + private static DmDeliveryStateEntry attemptRecipientRoutes( + DmDeliveryStateEntry current, + Long nextAttemptAtMs + ) throws Exception { + if (current == null) return null; + List routes = cappedRoutes(current.getToLogin()); + String routesHash = routesHash(routes); + String alreadyDelivered = normalize(current.getDeliveredServerLogin()); + List acceptedLogins = new ArrayList<>(); + if (alreadyDelivered != null) acceptedLogins.add(alreadyDelivered); + + 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 incomingBlobB64 = Base64.getEncoder().encodeToString(incoming.getRawBlock()); + String ownServerLogin = ownServerLogin(); + java.util.concurrent.CompletionService completion = + new java.util.concurrent.ExecutorCompletionService<>(RECIPIENT_EXECUTOR); + int submitted = 0; + for (UserAccessServerRouteEntry route : routes) { + String routeLogin = normalize(route.getServerLogin()); + if (routeLogin == null || acceptedLogins.contains(routeLogin)) continue; + completion.submit(() -> { + try { + if (!routeLogin.equals(ownServerLogin)) { + REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); + } + return new RouteAttempt(routeLogin, null); + } catch (Exception e) { + log.info("DM recipient server unavailable: messageKey={} server={}", + current.getOutgoingMessageKey(), routeLogin); + return new RouteAttempt(routeLogin, compactError(e)); + } + }); + submitted++; + } + + String lastError = null; + for (int i = 0; i < submitted && acceptedLogins.isEmpty(); i++) { + RouteAttempt result = completion.take().get(); + if (result.error() == null) { + acceptedLogins.add(result.serverLogin()); + } else { + lastError = result.error(); + } + } + + int nextState = acceptedLogins.isEmpty() + ? DmDeliveryStateEntry.ACCEPTED + : DmDeliveryStateEntry.DELIVERED; + String oneLogin = acceptedLogins.isEmpty() ? null : acceptedLogins.get(0); + return DELIVERY_DAO.updateAfterAttempt( + current.getEventId(), nextState, oneLogin, routesHash, + nextAttemptAtMs, lastError, System.currentTimeMillis()); + } + + private static DmDeliveryStateEntry handoffPairToSenderPeer(DmDeliveryStateEntry current) throws Exception { + UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin()); + if (peer == null) return current; + SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey()); + SignedMessageEntry outgoing = MESSAGES_DAO.getByMessageKey(current.getOutgoingMessageKey()); + if (incoming == null || outgoing == null) return current; + try { + REMOTE.sendMessagePair( + peer.getServerUrl(), + Base64.getEncoder().encodeToString(incoming.getRawBlock()), + Base64.getEncoder().encodeToString(outgoing.getRawBlock()), + ownServerLogin()); + OUTBOX_DAO.markSynced(current.getFromLogin(), current.getEventId()); + } catch (Exception e) { + log.info("DM sender peer unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin()); + } + return current; + } + + private static DmDeliveryStateEntry acceptPeerDeliveryStatus(DmDeliveryStateEntry current) throws Exception { + UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin()); + if (peer == null) return current; + try { + RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus( + peer.getServerUrl(), current.getOutgoingMessageKey()); + if (remote.known() && remote.delivered()) { + return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis()); + } + return current; + } catch (Exception e) { + log.info("DM delivery status unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin()); + return current; + } + } + + private static UserAccessServerRouteEntry senderPeer(String senderLogin) throws Exception { + String own = ownServerLogin(); + for (UserAccessServerRouteEntry route : cappedRoutes(senderLogin)) { + String routeLogin = normalize(route.getServerLogin()); + if (routeLogin != null && !routeLogin.equals(own)) return route; + } + return null; + } + + public static boolean isLocalAccessServer(String ownerLogin) throws Exception { + String own = ownServerLogin(); + if (own == null) return false; + for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) { + if (own.equals(normalize(route.getServerLogin()))) return true; + } + return false; + } + + public static boolean sourceIsOtherAccessServer(String ownerLogin, String sourceServerLogin) throws Exception { + String source = normalize(sourceServerLogin); + String own = ownServerLogin(); + if (source == null || source.equals(own)) return false; + for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) { + if (source.equals(normalize(route.getServerLogin()))) return true; + } + return false; + } + + public static String currentServerLogin() { + return ownServerLogin(); + } + + private static List cappedRoutes(String ownerLogin) throws Exception { + Map unique = new LinkedHashMap<>(); + for (UserAccessServerRouteEntry route : ACCESS_DAO.listByUserLogin(ownerLogin)) { + if (route == null || route.getServerUrl() == null || route.getServerUrl().isBlank()) continue; + String login = normalize(route.getServerLogin()); + if (login == null) continue; + unique.putIfAbsent(login, route); + if (unique.size() == 2) break; + } + return new ArrayList<>(unique.values()); + } + + private static String routesHash(List routes) throws Exception { + List logins = new ArrayList<>(); + for (UserAccessServerRouteEntry route : routes) { + String login = normalize(route.getServerLogin()); + if (login != null) logins.add(login); + } + logins.sort(String::compareTo); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(String.join("\n", logins).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 record RouteAttempt(String serverLogin, String error) {} + + 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); + } + + private static ThreadFactory daemonThreadFactory(String prefix) { + return new ThreadFactory() { + private int sequence; + @Override + public synchronized Thread newThread(Runnable r) { + Thread thread = new Thread(r, prefix + "-" + (++sequence)); + thread.setDaemon(true); + return thread; + } + }; + } +} 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/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java index cdd7c1df..a0071818 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 @@ -17,13 +17,19 @@ 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 { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); @@ -40,7 +46,11 @@ 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 { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); JsonNode response = send(serverAddressRaw, """ @@ -55,17 +65,54 @@ public final class RemoteDmSyncClient { ensureOk("ReceiveIncomingMessage", response); } + public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception { + String messageKeyJson = MAPPER.writeValueAsString(messageKey); + JsonNode response = send(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 { + try (RemoteSyncSession session = new RemoteSyncSession(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 +121,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 +134,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( @@ -106,9 +161,7 @@ public final class RemoteDmSyncClient { { "op":"DeleteMessage", "requestId":%s, - "payload":{ - "blobB64":%s - } + "payload":{"blobB64":%s} } """.formatted("%s", blobJson)); ensureOk("DeleteMessage", response); @@ -120,9 +173,7 @@ public final class RemoteDmSyncClient { { "op":"DeleteConversation", "requestId":%s, - "payload":{ - "blobB64":%s - } + "payload":{"blobB64":%s} } """.formatted("%s", blobJson)); ensureOk("DeleteConversation", response); @@ -132,24 +183,19 @@ public final class RemoteDmSyncClient { 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); - } + 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); @@ -157,9 +203,7 @@ public final class RemoteDmSyncClient { } 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()); } @@ -172,27 +216,14 @@ public final class RemoteDmSyncClient { } private static void tryAbort(WebSocket webSocket) { - try { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); - } catch (Exception ignored) { - } - try { - webSocket.abort(); - } catch (Exception ignored) { - } + 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 { @@ -214,9 +245,7 @@ public final class RemoteDmSyncClient { @Override public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { textBuffer.append(data); - if (last && !responseFuture.isDone()) { - responseFuture.complete(textBuffer.toString()); - } + if (last && !responseFuture.isDone()) responseFuture.complete(textBuffer.toString()); webSocket.request(1); return CompletableFuture.completedFuture(null); } @@ -230,16 +259,15 @@ public final class RemoteDmSyncClient { @Override public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { if (!responseFuture.isDone()) { - responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); + 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); - } + 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..a3a58691 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteSyncSession.java @@ -0,0 +1,70 @@ +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.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** Один последовательный WS-сеанс для синхронизации всех данных access-сервера. */ +public final class RemoteSyncSession implements AutoCloseable { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)).build(); + private final LinkedBlockingQueue responses = new LinkedBlockingQueue<>(); + private final WebSocket webSocket; + + public RemoteSyncSession(String serverAddressRaw) throws Exception { + String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); + if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); + Listener listener = new Listener(responses); + webSocket = HTTP.newWebSocketBuilder().connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener).get(8, TimeUnit.SECONDS); + } + + public synchronized JsonNode send(String jsonTemplate) throws Exception { + String requestId = MAPPER.writeValueAsString("access-sync-" + UUID.randomUUID()); + webSocket.sendText(jsonTemplate.formatted(requestId), true).get(8, TimeUnit.SECONDS); + String json = responses.poll(12, TimeUnit.SECONDS); + if (json == null) throw new TimeoutException("WS response timeout"); + return MAPPER.readTree(json); + } + + @Override + public void close() { + try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {} + } + + private static final class Listener implements WebSocket.Listener { + private final LinkedBlockingQueue responses; + private final StringBuilder text = new StringBuilder(); + + private Listener(LinkedBlockingQueue responses) { this.responses = responses; } + @Override public void onOpen(WebSocket ws) { ws.request(1); } + @Override public CompletionStage onText(WebSocket ws, CharSequence data, boolean last) { + text.append(data); + if (last) { + responses.offer(text.toString()); + text.setLength(0); + } + ws.request(1); + return CompletableFuture.completedFuture(null); + } + @Override public CompletionStage onBinary(WebSocket ws, ByteBuffer data, boolean last) { + ws.request(1); + return CompletableFuture.completedFuture(null); + } + @Override public void onError(WebSocket ws, Throwable error) { + responses.offer("{\"status\":500,\"code\":\"WS_ERROR\"}"); + } + } +} 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..3871acab 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 @@ -24,7 +24,13 @@ public final class RemoteUserSettingsSyncClient { .build(); public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception { - JsonNode response = send(serverAddressRaw, """ + try (RemoteSyncSession session = new RemoteSyncSession(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 +69,20 @@ public final class RemoteUserSettingsSyncClient { int limit, int maxBytes ) throws Exception { - JsonNode response = send(serverAddressRaw, """ + try (RemoteSyncSession session = new RemoteSyncSession(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, 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 index b982a910..b8445b4a 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java @@ -2,6 +2,7 @@ package server.sync; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport; import shine.db.dao.UserAccessServersCurrentDAO; import shine.db.dao.UserSettingsDAO; import shine.db.dao.UserSettingsSyncPeerStateDAO; @@ -14,6 +15,7 @@ import java.sql.Connection; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.ArrayList; import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -26,6 +28,7 @@ public final class PeriodicUserSettingsSyncService { 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 RemoteDmSyncClient DM_REMOTE = new RemoteDmSyncClient(); private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); private static final UserSettingsDAO SETTINGS_DAO = UserSettingsDAO.getInstance(); private static final UserSettingsSyncPeerStateDAO STATE_DAO = UserSettingsSyncPeerStateDAO.getInstance(); @@ -55,6 +58,9 @@ public final class PeriodicUserSettingsSyncService { TimeUnit.HOURS.toSeconds(periodHours), TimeUnit.SECONDS ); + EXECUTOR.scheduleWithFixedDelay( + PeriodicUserSettingsSyncService::runRequestedCycleSafe, + 5L, 5L, TimeUnit.SECONDS); log.info("Periodic user settings sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours); } @@ -66,6 +72,10 @@ public final class PeriodicUserSettingsSyncService { } } + private static void runRequestedCycleSafe() { + if (DmSyncWakeSignal.consume()) runCycleSafe(); + } + private static void runCycle() throws Exception { String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); if (ownServerLogin == null) { @@ -83,6 +93,7 @@ public final class PeriodicUserSettingsSyncService { int syncedPeers = 0; int appliedItems = 0; int pushedItems = 0; + int appliedDmItems = 0; for (String ownerLogin : owners) { List routes = ACCESS_DAO.listByUserLogin(ownerLogin); for (UserAccessServerRouteEntry route : routes) { @@ -96,6 +107,7 @@ public final class PeriodicUserSettingsSyncService { SyncStats stats = syncOwnerWithRemote(ownerLogin, route); appliedItems += stats.applied(); pushedItems += stats.pushed(); + appliedDmItems += stats.appliedDm(); syncedPeers++; } catch (Exception e) { STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e)); @@ -105,8 +117,8 @@ public final class PeriodicUserSettingsSyncService { } } - log.info("Periodic user settings sync cycle finished: owners={} syncedPeers={} appliedItems={} pushedItems={}", - owners.size(), syncedPeers, appliedItems, pushedItems); + log.info("Periodic access-data sync finished: owners={} peers={} settingsApplied={} settingsPushed={} dmApplied={}", + owners.size(), syncedPeers, appliedItems, pushedItems, appliedDmItems); } private static SyncStats syncOwnerWithRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception { @@ -124,9 +136,11 @@ public final class PeriodicUserSettingsSyncService { int pushed = 0; boolean bootstrapCompleted = false; + int appliedDm; + try (RemoteSyncSession session = new RemoteSyncSession(route.getServerUrl())) { for (int page = 0; page < maxPages; page++) { RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch( - route.getServerUrl(), + session, ownerLogin, cursorTimeMs, cursorSettingKey, @@ -163,17 +177,53 @@ public final class PeriodicUserSettingsSyncService { try (Connection c = getDbConnection()) { List unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit); for (UserSettingEntry entry : unsynced) { - REMOTE.upsertUserSetting(route.getServerUrl(), entry, true); + REMOTE.upsertUserSetting(session, entry, true); SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey()); pushed++; } } + int dmLimit = (int) configLong("dm.sync.batchLimit", 200L, 1L, 500L); + int dmMaxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); + int dmMaxPages = (int) configLong("dm.sync.maxPagesPerPeer", 20L, 1L, 500L); + appliedDm = syncDmInSameSession(session, ownerLogin, dmLimit, dmMaxBytes, dmMaxPages); + } + 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); + return new SyncStats(applied, pushed, appliedDm); + } + + private static int syncDmInSameSession( + RemoteSyncSession session, String ownerLogin, int limit, int maxBytes, int maxPages + ) throws Exception { + long cursorMs = 0L; + String cursorKey = ""; + List acknowledgements = new ArrayList<>(); + int applied = 0; + for (int page = 0; page < maxPages; page++) { + RemoteDmSyncClient.RemoteDmBatch batch = DM_REMOTE.dmSyncBatch( + session, ownerLogin, cursorMs, cursorKey, Math.min(limit, 500), maxBytes, acknowledgements); + acknowledgements = new ArrayList<>(); + for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) { + if (item == null || item.syncId() == null || item.syncId().isBlank()) continue; + DmSyncApplySupport.applySyncedItem(ownerLogin, item.syncId(), item.blobsB64()); + acknowledgements.add(item.syncId()); + applied++; + } + cursorMs = batch.nextStoredAtMs(); + cursorKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey(); + if (!batch.hasMore() || batch.items().isEmpty()) { + if (!acknowledgements.isEmpty()) { + DM_REMOTE.dmSyncBatch(session, ownerLogin, 0L, "", + Math.min(limit, 500), maxBytes, acknowledgements); + } + break; + } + } + return applied; } private static java.sql.Connection getDbConnection() throws Exception { @@ -202,5 +252,5 @@ public final class PeriodicUserSettingsSyncService { return s.isEmpty() ? null : s; } - private record SyncStats(int applied, int pushed) {} + private record SyncStats(int applied, int pushed, int appliedDm) {} } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 17aa12c4..374250c5 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -34,13 +34,13 @@ sync.importUserProfileFromPartner.enabled=false # ------------------------------------------------------------ server.version=${projectVersion} -# Межсерверная догоняющая синхронизация личных сообщений. +# Доставка и межсерверная синхронизация личных сообщений. dm.sync.enabled=true -dm.sync.initialDelaySeconds=60 -dm.sync.periodHours=6 -dm.sync.batchLimit=500 +dm.worker.pollSeconds=5 +dm.worker.dueLimit=100 +dm.sync.batchLimit=200 dm.sync.batchMaxBytes=3000000 -dm.sync.maxPagesPerPeer=50 +dm.sync.maxPagesPerPeer=20 server.info.url= server.info.physicalRegion= server.info.description= diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 2716626c..c50f48d0 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -62,11 +62,12 @@ | `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` | +| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | старая полная DM-пара для второго access-сервера отправителя | | `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 по курсору | +| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | pull событий `synced=false` с ACK предыдущей страницы | +| `GetDmDeliveryStatus` | `12_Direct_Messages_Push_Calls_API.md` | read-only статус доставки по существующему `messageKey` | | `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору | | `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных | | `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога | @@ -76,7 +77,8 @@ ## Важные замечания -- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. +- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload. +- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее. - Отдельных 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..28e3cce2 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -107,12 +107,17 @@ "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` @@ -147,7 +152,9 @@ } ``` -`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику. +`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. + +Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно. ### Примечание @@ -243,6 +250,7 @@ "reencryptedAtMs": 0, "createdAtMs": 1774700001123, "readAtMs": 1774700001456, + "deliveryState": "delivered", "blobB64": "BASE64_SIGNED_BLOCK" } ] @@ -252,11 +260,50 @@ Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа. -## 8. `DmSyncBatch` +Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`. -Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`. +## 8. Межсерверные операции DM -### Запрос +До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи. + +### 8.1. `ReceiveOutcomingMessage` + +Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит. + +```json +{ + "op": "ReceiveOutcomingMessage", + "requestId": "dm-peer-001", + "payload": { + "incomingBlobB64": "BASE64_INCOMING", + "outgoingBlobB64": "BASE64_OUTGOING", + "sourceServerLogin": "server-a" + } +} +``` + +Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки. + +### 8.2. `ReceiveIncomingMessage` + +Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя. + +```json +{ + "op": "ReceiveIncomingMessage", + "requestId": "dm-incoming-001", + "payload": { + "incomingBlobB64": "BASE64_INCOMING", + "sourceServerLogin": "server-a" + } +} +``` + +Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись). + +### 8.3. `DmSyncBatch` + +Pull-синхронизация событий владельца с `synced=false`. `ackSyncIds` подтверждает версии событий, успешно сохранённые из предыдущего ответа. ```json { @@ -264,55 +311,58 @@ "requestId": "dm-sync-001", "payload": { "ownerLogin": "alice", - "afterStoredAtMs": 1774700000000, - "afterMessageKey": "alice|bob|1774699999000|123456780|2", + "afterStoredAtMs": 0, + "afterMessageKey": "", "limit": 500, - "maxBytes": 3000000 + "maxBytes": 3000000, + "ackSyncIds": [] } } ``` -`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`. - -### Успешный ответ +Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла: ```json { - "op": "DmSyncBatch", - "requestId": "dm-sync-001", - "status": 200, - "ok": true, + "nextStoredAtMs": 1774700001123, + "nextMessageKey": "alice|bob|1774700000123|123456789|2", + "hasMore": false, + "items": [ + { + "syncId": "alice|bob|1774700000123|123456789|2:0:0", + "primaryMessageKey": "alice|bob|1774700000123|123456789|2", + "storedAtMs": 1774700001123, + "blobsB64": ["BASE64_INCOMING", "BASE64_OUTGOING"] + } + ] +} +``` + +Для пары порядок всегда incoming/outgoing; для входящей копии и tombstone массив содержит один blob. `syncId` — технический идентификатор конкретной ревизии только для ACK синхронизации, а не второй ID сообщения. После сохранения вызывающий сервер передаёт `syncId` в `ackSyncIds` следующего запроса. Только тогда источник ставит `synced=true`. Новый цикл начинается с `afterStoredAtMs=0`; полный сброс флагов поэтому повторно отдаёт всю историю. + +### 8.4. `GetDmDeliveryStatus` + +Единственная новая межсерверная операция доставки. Read-only проверка существующего `messageKey`; не изменяет состояние отвечающего сервера. + +```json +{ + "op": "GetDmDeliveryStatus", + "requestId": "dm-status-001", "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" - } - ] + "messageKey": "alice|bob|1774700000123|123456789|2" } } ``` -События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`. +Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя. -Ошибки: +Периодический процесс использует одно WS-соединение с peer: сначала синхронизирует настройки, затем вызывает `DmSyncBatch` до завершения страниц и ACK. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает `dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет. -- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin` -- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя -- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login` +Основные ошибки межсерверных операций: + +- `400 / EMPTY_OWNER_LOGIN`, `EMPTY_MESSAGE_KEY`; +- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER`; +- `500 / LOCAL_SERVER_NOT_CONFIGURED`. ## 9. `AckSessionDelivery` @@ -355,6 +405,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..3cecdacb 100644 --- a/docs/API/13_User_Settings_API.md +++ b/docs/API/13_User_Settings_API.md @@ -77,15 +77,17 @@ Внутренний служебный запрос. - помечает все настройки пользователя или все настройки сразу как `synced=false`; +- одновременно сбрасывает DM-outbox того же пользователя (или всех пользователей), чтобы операция замены/добавления access-сервера не оставила историю DM несинхронизированной; +- ответ дополнительно содержит `dmUpdated` — количество сброшенных DM-событий; - нужен после добавления нового sync-сервера или при потере локальной БД. ## 5. Синхронизация -Синхронизация настроек работает отдельно от DM. +Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс открывает один последовательный WS-сеанс с peer: сначала синхронизирует настройки, затем забирает `DmSyncBatch`. - локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер; - если запись пришла с другого сервера, она сохраняется сразу как `synced=true`; -- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору; +- периодический sync раз в 6 часов проверяет несинхронизированные настройки, догружает их по курсору и затем забирает несинхронизированные DM; - если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля. ## 6. Текущий UI-кейс diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index 6c08aac4..79fc7fc8 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -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_sync_outbox.synced`, без постоянного time-cursor. +- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. ### 3.2 Блоки пользовательского блокчейна @@ -181,7 +183,9 @@ Full resync запускается только тогда, когда: Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. -## 5. Целевой протокол следующего этапа +## 5. Возможное развитие server-to-server транспорта + +Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth. ### 5.1 Межсерверное соединение @@ -192,14 +196,15 @@ Full resync запускается только тогда, когда: ### 5.2 Доставка новых данных (push) -- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам. +- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам. - Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. +- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. ### 5.3 Начальная синхронизация (backfill) -- При первом подключении к партнёру серверы обмениваются «курсорами» состояния: - последний глобальный номер блока, последний известный DM-ключ. +- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов. - Сервер с более полной историей досылает недостающее партнёру. +- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`. ### 5.4 Разрешение конфликтов @@ -212,19 +217,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` B. +4. ACK хотя бы одного маршрута B означает терминальный `delivered`; второй сервер B догоняется собственной DM-синхронизацией. +5. После первой попытки X передаёт полную пару второму access-серверу A старым `ReceiveOutcomingMessage`, без delivery-state. +6. При нулевой доставке выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. Перед тремя последними X спрашивает peer A через `GetDmDeliveryStatus(messageKey)`. +7. После неудачной попытки через час ставится терминальный `failed`. -Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения). +Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке. ## 7. Безопасность - Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. -- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа). +- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами. - При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. +- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным. ## 8. Статус реализации @@ -240,15 +247,18 @@ Full resync запускается только тогда, когда: | Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | | Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | | Межсерверный постоянный WebSocket-канал | Нужна реализация | -| Push новых DM партнёрам | Нужна реализация | +| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано | +| Retry DM до 1 часа + UI-state | ✅ Реализовано | +| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано | +| Read-only `GetDmDeliveryStatus` | ✅ Реализовано | | Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | | Periodic backfill отсутствующего хвоста | ✅ Реализовано | | Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | | Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | -| Маршрутизация DM через access_servers | Нужна реализация (заглушка) | +| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано | +| Криптографическая server-to-server авторизация DM | Нужна реализация | -Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами. -Не реализованы ещё DM-sync и постоянные server-to-server соединения. +Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов. Следующие отдельные шаги после текущего этапа: - отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 0f24f649..a28b5cd4 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -6,6 +6,7 @@ - `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` - `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` +- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — состояния доставки, retry-воркер, репликация между двумя access-серверами и 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..21391c87 --- /dev/null +++ b/docs/Personal_Messages/Доставка_и_синхронизация_DM.md @@ -0,0 +1,136 @@ +# Доставка и синхронизация личных сообщений + +## 1. Главный принцип + +DM считается доставленным, когда signed-входящую копию сохранил хотя бы один актуальный access-сервер получателя. Доставка на оба сервера не требуется: сервер получателя самостоятельно синхронизирует входящее сообщение со своим вторым сервером. + +Клиентский `status=200` от `SendMessagePair` означает, что собственный сервер сохранил пару. Это отдельный факт от доставки получателю. + +Формат подписанного контейнера `SHiNE_DM` не меняется. Состояние доставки и флаг синхронизации — изменяемые серверные метаданные. + +## 2. Идентификаторы + +- `baseKey` связывает входящую и исходящую копии одного логического сообщения; +- `incomingKey` идентифицирует копию получателя; +- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки; +- дополнительный публичный `eventId` для доставки не создаётся; +- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`. + +## 3. Состояния доставки + +| Состояние | Смысл | Повторные попытки | +|---|---|---| +| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да | +| `delivered` | хотя бы один сервер получателя подтвердил запись | нет | +| `failed` | последняя попытка через час завершилась без доставки | никогда | + +Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя. + +## 4. Обычная отправка + +1. Клиент отправляет `SendMessagePair` на один свой access-сервер. +2. Сервер проверяет формат, пользователей, подписи и согласованность пары. +3. Сервер атомарно сохраняет входящую и исходящую копии. +4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя. +5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно. +6. Если хотя бы один вызов успешен, устанавливается `delivered`. +7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`. +8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`. + +Успешный повтор уже сохранённого signed-блока считается ACK. Все операции должны быть идемпотентными. + +## 5. Расписание повторов + +Воркер запускается каждые 5 секунд и выбирает только due-строки по индексу. Он не сканирует всю таблицу сообщений. + +Попытки привязаны к времени первоначального принятия: + +| Номер | Время от старта | Сначала спросить второй сервер отправителя | +|---:|---:|---| +| 1 | сразу | нет | +| 2 | 30 секунд | нет | +| 3 | 5 минут | да | +| 4 | 25 минут | да | +| 5 | 1 час | да | + +Из-за шага воркера повтор может начаться на несколько секунд позже указанного времени. Первая попытка выполняется немедленно и от воркера не зависит. + +На 5-й, 25-й и 60-й минутах сервер сначала вызывает у второго сервера отправителя: + +```json +{ + "op": "GetDmDeliveryStatus", + "payload": { + "messageKey": "alice|bob|1774700000123|123456789|2" + } +} +``` + +Ответ: + +```json +{ + "messageKey": "alice|bob|1774700000123|123456789|2", + "known": true, + "delivered": true +} +``` + +Операция read-only. Если peer отвечает `delivered=true`, локальный сервер устанавливает `delivered` и не обращается к серверам получателя. Ошибка или отсутствие операции на старом peer не блокирует собственную попытку. + +Перед каждой попыткой маршруты получателя заново читаются из `user_access_servers_current`. Изменение серверов учитывается только пока сообщение находится в `accepted`. После `delivered` или `failed` состояние больше не открывается. + +Если последняя проверка и попытка через час не дали ACK, устанавливается `failed`, `next_attempt_at_ms` очищается и сообщение больше никогда автоматически не отправляется. + +## 6. Старая межсерверная доставка + +Форматы существующих операций не расширяются данными о результате доставки: + +- `ReceiveOutcomingMessage` получает прежнюю пару `incomingBlobB64` + `outgoingBlobB64` и необязательный `sourceServerLogin`; +- `ReceiveIncomingMessage` получает один `incomingBlobB64` и необязательный `sourceServerLogin`. + +Второй сервер отправителя после получения пары создаёт собственное локальное состояние доставки и самостоятельно пробует маршруты получателя. Серверы обмениваются результатом только через read-only `GetDmDeliveryStatus`. + +## 7. Догоняющая синхронизация двух серверов пользователя + +Для каждого владельца сервер ведёт outbox с флагом `synced`: + +- исходящая пара отправителя — один элемент с двумя blob в порядке incoming/outgoing; +- входящая копия получателя — один элемент с одним blob; +- read-receipt и tombstone применяются теми же проверенными обработчиками. + +`DmSyncBatch` возвращает только элементы с `synced=false`. Получатель проверяет signed-блоки, сохраняет их идемпотентно и в следующем запросе подтверждает `syncId` через `ackSyncIds`. Источник ставит `synced=true` только после ACK. + +При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля. + +Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы. + +Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь. + +## 8. UI + +| Вид | Значение | +|---|---| +| одна серая галочка | собственный сервер принял сообщение (`accepted`) | +| одна светлая галочка | хотя бы один сервер получателя принял сообщение (`delivered`) | +| две светлые галочки | пришёл существующий read-receipt | +| красный `!` и «Сообщение не доставлено» | окончательное состояние `failed` | + +Read-receipt имеет приоритет над delivery-индикатором: если сообщение прочитано, оно заведомо было доставлено. + +Сервер сообщает поздние изменения через `DmDeliveryStateChanged` с полями `outgoingKey`, `baseKey`, `deliveryState`. При повторном открытии чата то же состояние приходит в `GetDirectMessages`. + +## 9. Нагрузка и отказоустойчивость + +- воркер выбирает только due-записи по частичному индексу; +- терминальные строки не попадают в рабочую выборку; +- два маршрута первой попытки выполняются параллельно; +- ограниченный пул потоков и очередь защищают сервер при всплеске отправок; +- сетевой lease и сравнение версии строки предотвращают одновременную обработку одной задачи несколькими worker-потоками; +- повторная запись одного signed-блока безопасна; +- отсутствие второго сервера отправителя не мешает собственной доставке; +- отсутствие обоих серверов получателя завершает задачу через час. + +## 10. Граница доверия + +Межсерверная авторизация пока отложена. `sourceServerLogin` временно принимается на доверии, но каждый контейнер `SHiNE_DM` всё равно проходит проверку пользовательской подписи. `GetDmDeliveryStatus` сообщает только факт локального delivery-state и не изменяет данные. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 807b5adf..c858cc3c 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -26,6 +26,10 @@ - `docs/Personal_Messages/Технические_вставки_DM_v1.md` +Изменяемое состояние доставки, retry-расписание и репликация между двумя access-серверами подробно описаны отдельно: + +- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` + Устаревшая предыдущая версия сохранена отдельно: - `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` @@ -300,12 +304,17 @@ `sync_servers` не являются списком пользовательских серверов доставки DM. -### 7.3. Несколько серверов у отправителя и получателя +### 7.3. Два сервера у отправителя и получателя -Протокол должен поддерживать ситуацию, когда: +Актуальный runtime исходит из ограничения: -- у отправителя несколько `access_servers`; -- у получателя несколько `access_servers`; +- у одного пользователя не более двух `access_servers`; +- следовательно, у локального сервера есть не более одного peer для репликации данных пользователя. + +Протокол поддерживает ситуацию, когда: + +- у отправителя один или два `access_servers`; +- у получателя один или два `access_servers`; - часть серверов у сторон совпадает; - часть серверов уникальна. @@ -332,7 +341,14 @@ - `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt; - `ReceiveOutcomingMessage` — алиас `SendMessagePair`. -### 8.2. Новые методы, которые нужны +### 8.2. Межсерверная синхронизация + +- `ReceiveOutcomingMessage` — прежняя полная пара второго access-сервера отправителя; +- `ReceiveIncomingMessage` — прежняя одиночная входящая копия; +- `DmSyncBatch` — pull событий с `synced=false` и ACK сохранённой предыдущей страницы; +- `GetDmDeliveryStatus` — единственная новая read-only проверка доставки по существующему `messageKey`. + +Delivery-state между серверами не передаётся и не объединяется. ### 8.3. Серверный слой диалогов @@ -446,8 +462,12 @@ Request: Правила: - клиенту достаточно отправить пару на один любой доступный сервер; +- успешный `status=200` означает локальное сохранение; +- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя; - сервер после принятия сам отвечает за дальнейшую межсерверную доставку. +Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`. + ### 10.2. `ReceiveIncomingMessage` Назначение: @@ -469,7 +489,7 @@ Request: } ``` -`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику. +`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки. ### 10.3. `DeleteMessage` @@ -558,6 +578,8 @@ UI-следствие для клиента: - если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени; - read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается. +Для исходящих элементов сервер также возвращает единственное поле `deliveryState`. + ## 11. Межсерверная доставка ### 11.1. Клиентская сторона @@ -568,47 +590,23 @@ UI-следствие для клиента: ### 11.2. Серверная сторона -После принятия валидного события сервер должен отправлять его: +После локального принятия пара получает изменяемое состояние `accepted`. Сервер сразу вызывает до двух текущих маршрутов получателя параллельно. Успех хотя бы одного маршрута переводит сообщение в терминальное `delivered`; ждать второй маршрут не требуется. -- на все серверы из `access_servers` отправителя; -- на все серверы из `access_servers` получателя. +После первой попытки полная пара передаётся единственному второму access-серверу отправителя через прежний `ReceiveOutcomingMessage`. Peer самостоятельно пробует актуальные маршруты получателя; delivery-state в запрос не входит. -Если часть серверов совпадает, это допустимо. - -Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии. - -Идемпотентность обязательна. +Идемпотентность обязательна. ACK означает запись в БД; повтор уже известной ревизии считается успешным ACK. ### 11.3. Догоняющая синхронизация истории -Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция: +Для восстановления пропущенных DM-событий между access-серверами используется pull-операция: - `DmSyncBatch` -Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору: +Второй сервер запрашивает outbox-события владельца с `synced=false`, сохраняет их и передаёт подтверждённые `syncId` в `ackSyncIds` следующего запроса. Источник ставит `synced=true` только после ACK. Каждый цикл начинается с начала списка; курсор нужен только для страниц текущего цикла. -- `ownerLogin`; -- `afterStoredAtMs`; -- `afterMessageKey`; -- `limit`, максимум `500`; -- `maxBytes`, ограничение суммарного размера raw-блоков пачки. +Полная пара отправителя передаётся двумя blob. Входящая копия получателя и tombstone передаются одним blob. -Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю: - -- контентные копии и read-receipt по `target_login`; -- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`. - -Порядок пачки: - -- `created_at_ms ASC`; -- `message_key ASC`. - -Курсор хранится локально для пары: - -- пользователь; -- удалённый access-сервер. - -При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками. +Полная пара отправителя передаётся одним элементом с двумя blob в порядке incoming/outgoing. Входящая копия получателя и tombstone передаются одним blob. При применении событий, полученных через `DmSyncBatch`, сервер: @@ -618,17 +616,17 @@ UI-следствие для клиента: - не отправляет realtime/push-уведомления клиентам; - не запускает повторный fan-out, чтобы не создавать циклы. -Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов. +Синхронизация настроек и DM выполняется одним периодическим процессом и через один последовательный WS-сеанс с peer. Выборка DM использует частичный индекс по `synced=false`. -В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`. +В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков. ### 11.4. Ошибки доставки -Если часть серверов временно недоступна: +Если ни один сервер получателя не подтвердил запись, попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час от первоначального принятия. -- это не должно отменять локальное принятие уже валидного сообщения; -- повторная доставка может делаться отдельным retry-механизмом; -- повторное получение того же события должно быть безопасным. +Перед попытками через 5 минут, 25 минут и час сервер read-only спрашивает peer отправителя через `GetDmDeliveryStatus(messageKey)`. Если peer уже доставил хотя бы на один сервер, сообщение считается доставленным. + +После неудачной последней попытки устанавливается `failed`; дальнейших автоматических попыток и кнопки ручного повтора нет. ## 12. Хранение в БД @@ -653,11 +651,13 @@ UI-следствие для клиента: Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. -Для догоняющей межсерверной синхронизации дополнительно используются: +Изменяемая сетевая часть хранится отдельно: -- индекс по `target_login`, `created_at_ms`, `message_key`; -- отдельные индексы по delete-событиям для `from_login` и `to_login`; -- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`. +- `dm_delivery_state` — состояние и расписание доставки исходящей пары; +- `dm_sync_outbox` — событие владельца и единственный флаг ACK `synced`; +- частичные индексы содержат только due/unsynced строки. + +Legacy-таблица `dm_sync_peer_state` после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется. ## 13. Что обязательно должно измениться в коде относительно v0.5 @@ -670,7 +670,7 @@ UI-следствие для клиента: - межсерверная маршрутизация DM должна идти через `access_servers`; - сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM; - при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает; -- логика должна быть безопасна для нескольких серверов у каждой стороны. +- логика должна быть безопасна для одного или двух серверов у каждой стороны. ## 14. Что в v1 пока не входит @@ -678,4 +678,4 @@ UI-следствие для клиента: - хранение отдельного `keyId` шифрования в DM; - ротация `clientKey`; - финальная конкретная UI-реализация массовой перешифровки; -- межсерверная авторизация `DmSyncBatch`. +- межсерверная авторизация DM-синхронизации и доставки. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index b2bdaf10..14d6ddb3 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,4 @@ ReadReceiptBody_v1_0 В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. -Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`. +Межсерверные операции `ReceiveOutcomingMessage`, `ReceiveIncomingMessage` и `DmSyncBatch` не вводят новый байтовый формат DM. Они передают уже сохранённые raw-контейнеры `SHiNE_DM` в Base64. `ackSyncIds` подтверждает только факт сохранения синхронизированной ревизии; delivery-state между серверами не передаётся. Принимающий сервер заново проверяет подпись и применяет контейнер по его `messageType`. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 2a7ca626..b6dfb66b 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -43,6 +43,7 @@ import { deleteConversationMessagesBefore, deleteSignedMessageByBaseKey, markIncomingReadByBaseKey, + markOutgoingDeliveryState, markOutgoingReadByBaseKey, normalizeDmChatId, setContacts, @@ -1390,6 +1391,17 @@ async function init() { }); }); + authService.onEvent('DmDeliveryStateChanged', (evt) => { + const payload = evt?.payload || {}; + const changed = markOutgoingDeliveryState({ + outgoingKey: payload.outgoingKey, + baseKey: payload.baseKey, + deliveryState: payload.deliveryState, + }); + if (!changed) return; + window.dispatchEvent(new CustomEvent('shine-dm-delivery-updated', { detail: payload })); + }); + authService.onEvent('SignedMessageArrived', async (evt) => { const payload = evt?.payload || {}; const messageKey = String(payload.messageKey || '').trim(); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index bbaa1182..c0c74328 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -528,10 +528,28 @@ function resolveEffectiveReadState(messages, msg) { function resolveDeliveryStatus(messages, msg) { if (msg?.from !== 'out') return ''; if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓'; - if (msg?.firstTick) return '✓'; + const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); + if (deliveryState === 'delivered') return '✓'; + if (deliveryState === 'failed') return '!'; + if (deliveryState === 'accepted' || msg?.firstTick) return '✓'; return '…'; } +function resolveDeliveryTone(messages, msg) { + if (resolveEffectiveReadState(messages, msg).isRead) return 'read'; + const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); + if (deliveryState === 'delivered') return 'delivered'; + if (deliveryState === 'failed') return 'failed'; + return 'accepted'; +} + +function resolveDeliveryNote(msg) { + if (msg?.from !== 'out') return ''; + const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); + if (deliveryState === 'failed') return 'Сообщение не доставлено.'; + return ''; +} + function resolveMessageEditedTimeMs(msg) { const revisionTimeMs = Number(msg?.revisionTimeMs || 0); if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0; @@ -744,13 +762,21 @@ function renderLog( const status = resolveDeliveryStatus(messages, msg); if (status) { const statusNode = document.createElement('span'); - statusNode.className = 'bubble-status'; + statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`; statusNode.textContent = status; metaNode.append(statusNode); } bubble.append(metaNode); + const deliveryNote = resolveDeliveryNote(msg); + if (deliveryNote) { + const deliveryNoteNode = document.createElement('div'); + deliveryNoteNode.className = `bubble-delivery-note${String(msg?.deliveryState || '') === 'failed' ? ' bubble-delivery-note--failed' : ''}`; + deliveryNoteNode.textContent = deliveryNote; + bubble.append(deliveryNoteNode); + } + const editedAtMs = resolveMessageEditedTimeMs(msg); if (editedAtMs > 0) { const editedNode = document.createElement('div'); @@ -831,6 +857,7 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) { readAtMs: Number(item?.readAtMs || 0), rawBlobB64: blobB64, revisionTimeMs: Number(item?.revisionTimeMs || parsed?.revisionTimeMs || 0), + deliveryState: String(item?.deliveryState || ''), }); } catch (error) { addAppLogEntry({ @@ -1248,7 +1275,12 @@ export function render({ navigate, route, chrome }) { focusInputToEnd(); }; - const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { + const applyLocalRevision = async ({ + localOutgoingBlobB64, + fallbackMessageKey = '', + fallbackBaseKey = '', + deliveryState = '', + }) => { if (!localOutgoingBlobB64) return; try { const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); @@ -1271,6 +1303,7 @@ export function render({ navigate, route, chrome }) { unread: false, rawBlobB64: localOutgoingBlobB64, revisionTimeMs: Number(parsed?.revisionTimeMs || 0), + deliveryState, deleted: Boolean(parsed?.deleted), }); return true; @@ -1346,6 +1379,7 @@ export function render({ navigate, route, chrome }) { markOutgoingSent(tempId, { messageKey: result?.outgoingKey || '', baseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', }); } @@ -1353,6 +1387,7 @@ export function render({ navigate, route, chrome }) { localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', }); if (editing) { @@ -1580,7 +1615,14 @@ export function render({ navigate, route, chrome }) { void sendReadReceiptsForVisible(chatId); }; + const handleDeliveryRefresh = () => { + preserveComposerSelection(input, () => { + renderChatLog({ scrollMode: 'preserve', markAsRead: false }); + }); + }; + window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); + window.addEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); wrap.append(historyLoader, log); screen.append(wrap); @@ -1609,6 +1651,7 @@ export function render({ navigate, route, chrome }) { hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); + window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); clearUnreadSeparatorHideTimer(); document.body.classList.remove('chat-topbar-overlay'); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index b5a956d3..d52d2916 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -17,6 +17,7 @@ const DM_BLOB_PREVIEW_CACHE = new Map(); const DM_BLOB_PREVIEW_PENDING = new Map(); const dmAvatarSnapshotCache = new Map(); const dmAvatarPendingByLogin = new Map(); +const SVG_CHEVRON = ''; const RELATION_ORDER = new Map([ ['close_friend', 0], @@ -166,6 +167,7 @@ function compareChatRows(a, b) { export function render({ navigate, chrome }) { const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-list-screen'; + const login = String(state.session.login || '').trim(); const head = document.createElement('header'); head.className = 'dm-head'; head.innerHTML = ` diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 46259365..e282d403 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -414,6 +414,7 @@ function persistMessageRecord(chatId, row) { readAtMs: Number(row.readAtMs || 0), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), + deliveryState: String(row.deliveryState || ''), ts: resolvedTs > 0 ? resolvedTs : Date.now(), }).catch(() => {}); } @@ -450,6 +451,7 @@ export async function hydrateMessagesFromStore() { readAtMs: Number(row.readAtMs || 0), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), + deliveryState: String(row.deliveryState || ''), createdAtMs: Number(row.ts || 0), }); }); @@ -531,7 +533,11 @@ export function addOutgoingPendingMessage(chatId, text) { return tempId; } -export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) { +export function markOutgoingSent(tempId, { + messageKey = '', + baseKey = '', + deliveryState = 'accepted', +} = {}) { if (!tempId) return; const keys = Object.keys(state.chats || {}); keys.forEach((chatId) => { @@ -541,6 +547,7 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) row.firstTick = true; row.messageKey = messageKey || row.messageKey || ''; row.baseKey = baseKey || row.baseKey || ''; + row.deliveryState = String(deliveryState || row.deliveryState || 'accepted'); if (messageKey) { state.knownMessageKeys[messageKey] = true; persistMessageRecord(chatId, row); @@ -549,6 +556,33 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) }); } +export function markOutgoingDeliveryState({ + outgoingKey = '', + baseKey = '', + deliveryState = '', +} = {}) { + const normalizedOutgoingKey = String(outgoingKey || '').trim(); + const normalizedBaseKey = String(baseKey || '').trim(); + let changed = false; + Object.keys(state.chats || {}).forEach((chatId) => { + getChatMessages(chatId).forEach((row) => { + if (row?.from !== 'out') return; + const matches = ( + (normalizedOutgoingKey && String(row.messageKey || '') === normalizedOutgoingKey) + || (normalizedBaseKey && String(row.baseKey || '') === normalizedBaseKey) + ); + if (!matches) return; + row.deliveryState = String(deliveryState || row.deliveryState || 'accepted'); + if (row.deliveryState === 'delivered') { + row.firstTick = true; + } + persistMessageRecord(chatId, row); + changed = true; + }); + }); + return changed; +} + export function markOutgoingReadByBaseKey(baseKey, readAtMs = 0) { if (!baseKey) return; const normalizedReadAtMs = Number(readAtMs || 0); @@ -629,6 +663,7 @@ export function addSignedMessageToChat({ rawBlobB64 = '', refBaseKey = '', revisionTimeMs = 0, + deliveryState = '', deleted = false, } = {}) { const normalizedChatId = normalizeDmChatId(chatId); @@ -663,6 +698,7 @@ export function addSignedMessageToChat({ row.messageType = Number(messageType || 0); row.rawBlobB64 = String(rawBlobB64 || ''); row.revisionTimeMs = nextRevision; + row.deliveryState = String(deliveryState || existing?.deliveryState || (row.from === 'out' ? 'accepted' : '')); row.unread = row.from === 'in' ? Boolean(unread) : false; row.refBaseKey = String(refBaseKey || ''); row.firstTick = row.from === 'out'; diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 4da6a2a2..33590d4a 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1827,7 +1827,29 @@ .bubble-status { min-width: 14px; text-align: right; - color: rgba(215, 230, 255, 0.92); + color: rgba(148, 163, 184, 0.92); +} + +.bubble-status--delivered, +.bubble-status--read { + color: rgba(215, 230, 255, 0.96); + text-shadow: 0 0 6px rgba(126, 180, 255, 0.36); +} + +.bubble-status--failed { + color: rgba(255, 145, 145, 0.96); +} + +.bubble-delivery-note { + max-width: 280px; + margin-top: 6px; + font-size: 10px; + line-height: 1.3; + color: rgba(255, 220, 142, 0.92); +} + +.bubble-delivery-note--failed { + color: rgba(255, 145, 145, 0.96); } .bubble.in { From 7d929e626e35dc7bb3264053ed119ab54813b5a220bfd5054aa59dc0f3191a31 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 25 Aug 2026 19:30:44 +0400 Subject: [PATCH 02/15] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20DM-=D0=B2=D1=8B=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/Net_ServerHello_Handler.java | 70 ++ .../entyties/Net_ServerHello_Request.java | 20 + .../entyties/Net_ServerHello_Response.java | 24 + .../messages/Net_SendMessagePair_Handler.java | 3 +- .../server/sync/ServerConnectionPool.java | 763 ++++++++++++++++++ docs/API/12_Direct_Messages_Push_Calls_API.md | 8 +- docs/API/16_Server_Connection_Pool_API.md | 105 +++ .../Доставка_и_синхронизация_DM.md | 2 + docs/Personal_Messages/Протокол_DM_v1.md | 7 +- 9 files changed, 996 insertions(+), 6 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ServerHello_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ServerHello_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/ServerConnectionPool.java create mode 100644 docs/API/16_Server_Connection_Pool_API.md 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/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 609dba1d..8b54d97e 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 @@ -48,8 +48,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessageEntry incomingEntry; SignedMessageEntry outgoingEntry; - boolean fromPeer = "ReceiveOutcomingMessage".equalsIgnoreCase(req.getOp()) - || !isBlank(req.getSourceServerLogin()); + boolean fromPeer = !isBlank(req.getSourceServerLogin()); try { String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; 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/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index 28e3cce2..ef6a9476 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -152,7 +152,7 @@ } ``` -`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. +`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. Пустой `sourceServerLogin` трактуется как клиентский вызов, непустой - как peer-вызов. Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно. @@ -356,7 +356,11 @@ Pull-синхронизация событий владельца с `synced=fal Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя. -Периодический процесс использует одно WS-соединение с peer: сначала синхронизирует настройки, затем вызывает `DmSyncBatch` до завершения страниц и ACK. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает `dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет. +Периодический процесс использует один логический последовательный сеанс поверх +постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает +`DmSyncBatch` до завершения страниц и ACK. Существующий +`MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает +`dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет. Основные ошибки межсерверных операций: 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..a354f1f6 --- /dev/null +++ b/docs/API/16_Server_Connection_Pool_API.md @@ -0,0 +1,105 @@ +# Межсерверное соединение и `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, deletes, `GetDmDeliveryStatus` | +| `NORMAL` | настройки, `DmSyncBatch` и access-data sync | +| `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/Personal_Messages/Доставка_и_синхронизация_DM.md b/docs/Personal_Messages/Доставка_и_синхронизация_DM.md index 21391c87..accb9e6e 100644 --- a/docs/Personal_Messages/Доставка_и_синхронизация_DM.md +++ b/docs/Personal_Messages/Доставка_и_синхронизация_DM.md @@ -103,6 +103,8 @@ DM считается доставленным, когда signed-входящу При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля. +`sourceServerLogin` является единственным признаком межсерверного вызова для этих операций: если поле пустое, запрос считается клиентским и его запись не должна сразу переводиться в `synced=true`. + Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index c858cc3c..9aa8d277 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -489,7 +489,7 @@ Request: } ``` -`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки. +`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки. ### 10.3. `DeleteMessage` @@ -616,7 +616,10 @@ UI-следствие для клиента: - не отправляет realtime/push-уведомления клиентам; - не запускает повторный fan-out, чтобы не создавать циклы. -Синхронизация настроек и DM выполняется одним периодическим процессом и через один последовательный WS-сеанс с peer. Выборка DM использует частичный индекс по `synced=false`. +Синхронизация настроек и DM выполняется одним периодическим процессом и через +один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие +этого логического сеанса не закрывает физическое соединение с peer. Выборка DM +использует частичный индекс по `synced=false`. В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков. From 3a5851939ed77ca86b8cfce451e85cf8373ea314475f1f850039ca4ffaa220cc Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 25 Aug 2026 19:31:38 +0400 Subject: [PATCH 03/15] =?UTF-8?q?=D0=9F=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BC=D0=B5=D0=B6=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D1=83=D1=8E=20=D1=80=D0=B5=D0=BF=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Репликация личных сообщений между серверами теперь работает корректно. --- .../db/dao/UserAccessServersCurrentDAO.java | 29 ++++ .../ws_protocol/JSON/ConnectionContext.java | 26 +++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../Net_UpsertUserSetting_Handler.java | 2 +- .../java/server/sync/AddBlockSyncService.java | 142 ++-------------- .../server/sync/DmDeliveryCoordinator.java | 6 +- .../java/server/sync/DmFederationService.java | 14 +- .../sync/RemoteBlockchainSyncClient.java | 159 +++--------------- .../java/server/sync/RemoteDmSyncClient.java | 150 +++++++---------- .../java/server/sync/RemoteSyncSession.java | 73 +++----- .../sync/RemoteUserSettingsSyncClient.java | 110 +++--------- .../BlockchainResyncRecoveryOnStartup.java | 3 +- .../sync/PeriodicBlockchainSyncService.java | 8 +- .../sync/PeriodicUserSettingsSyncService.java | 2 +- .../src/main/java/server/ws/README.md | 8 + .../src/main/java/server/ws/WsServer.java | 5 + .../src/main/resources/application.properties | 6 + deploy/backup/backup-version.properties | 4 +- docs/API/09_Operations_Index.md | 2 + docs/API/13_User_Settings_API.md | 5 +- docs/Blockchain/sync-between-servers.md | 22 ++- 21 files changed, 267 insertions(+), 513 deletions(-) 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..c53d0120 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 @@ -75,6 +75,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-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..37bf1a9a 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-соединения. * Живёт ровно столько же, сколько живёт подключение. @@ -77,6 +79,12 @@ public class 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 +95,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() { @@ -188,6 +210,10 @@ public class ConnectionContext { authenticationStatus = AUTH_STATUS_NONE; wsSession = null; + serverConnection = false; + remoteServerLogin = null; + remoteServerProtocolVersion = 0; + remoteServerCapabilities = List.of(); } @Override 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 336aaf5f..a7b5260a 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 @@ -133,6 +133,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; @@ -141,6 +142,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; @@ -232,6 +234,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()), @@ -323,6 +326,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/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..c30a6b32 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 @@ -123,7 +123,7 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue; if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue; try { - REMOTE.upsertUserSetting(remoteUrl, entry, true); + REMOTE.upsertUserSetting(remoteLogin, remoteUrl, entry, true); delivered++; } catch (Exception e) { log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e)); 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 index 4b3223e0..4d9577f8 100644 --- 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 @@ -149,7 +149,8 @@ public final class DmDeliveryCoordinator { completion.submit(() -> { try { if (!routeLogin.equals(ownServerLogin)) { - REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); + REMOTE.receiveIncomingMessage( + routeLogin, route.getServerUrl(), incomingBlobB64, ownServerLogin); } return new RouteAttempt(routeLogin, null); } catch (Exception e) { @@ -188,6 +189,7 @@ public final class DmDeliveryCoordinator { if (incoming == null || outgoing == null) return current; try { REMOTE.sendMessagePair( + peer.getServerLogin(), peer.getServerUrl(), Base64.getEncoder().encodeToString(incoming.getRawBlock()), Base64.getEncoder().encodeToString(outgoing.getRawBlock()), @@ -204,7 +206,7 @@ public final class DmDeliveryCoordinator { if (peer == null) return current; try { RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus( - peer.getServerUrl(), current.getOutgoingMessageKey()); + peer.getServerLogin(), peer.getServerUrl(), current.getOutgoingMessageKey()); if (remote.known() && remote.delivered()) { return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis()); } 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..845a8778 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 @@ -27,12 +27,15 @@ public final class DmFederationService { String ownServerLogin = ownServerLogin(); for (UserAccessServerRouteEntry route : senderRoutes.values()) { if (isOwnServer(route, ownServerLogin)) continue; - REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin); + REMOTE.sendMessagePair( + route.getServerLogin(), 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); + REMOTE.receiveIncomingMessage( + route.getServerLogin(), route.getServerUrl(), incomingBlobB64, ownServerLogin); } } catch (Exception e) { log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); @@ -54,7 +57,8 @@ public final class DmFederationService { if (routeLogin == null) continue; if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue; if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue; - REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); + REMOTE.receiveIncomingMessage( + route.getServerLogin(), route.getServerUrl(), incomingBlobB64, ownServerLogin); } } catch (Exception e) { log.warn("DM federation incoming relay failed: to={}", toLogin, e); @@ -79,9 +83,9 @@ public final class DmFederationService { 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) { 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/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java index a0071818..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,37 +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 { + 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, @@ -50,10 +46,19 @@ public final class RemoteDmSyncClient { 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, @@ -66,8 +71,14 @@ public final class RemoteDmSyncClient { } 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(serverAddressRaw, """ + JsonNode response = send(targetServerLogin, serverAddressRaw, """ { "op":"GetDmDeliveryStatus", "requestId":%s, @@ -94,7 +105,21 @@ public final class RemoteDmSyncClient { int maxBytes, List ackSyncIds ) throws Exception { - try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) { + 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); } @@ -156,8 +181,12 @@ 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, @@ -168,8 +197,12 @@ public final class RemoteDmSyncClient { } 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, @@ -179,27 +212,12 @@ public final class RemoteDmSyncClient { 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 { @@ -215,60 +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 RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {} public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List items) {} public record RemoteDmItem( 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 index a3a58691..3f5bca1f 100644 --- 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 @@ -1,70 +1,35 @@ 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.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -/** Один последовательный WS-сеанс для синхронизации всех данных access-сервера. */ +/** + * Логический последовательный сеанс поверх общего постоянного WSS-пула. + * close() больше не закрывает физическое соединение с сервером. + */ public final class RemoteSyncSession implements AutoCloseable { - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final HttpClient HTTP = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(6)).build(); - private final LinkedBlockingQueue responses = new LinkedBlockingQueue<>(); - private final WebSocket webSocket; + private final String serverLogin; + private final String serverAddress; + /** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */ public RemoteSyncSession(String serverAddressRaw) throws Exception { - String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); - if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); - Listener listener = new Listener(responses); - webSocket = HTTP.newWebSocketBuilder().connectTimeout(Duration.ofSeconds(6)) - .buildAsync(URI.create(wsUrl), listener).get(8, TimeUnit.SECONDS); + this(null, serverAddressRaw); + } + + public RemoteSyncSession(String serverLogin, String serverAddressRaw) { + this.serverLogin = serverLogin; + this.serverAddress = serverAddressRaw; } public synchronized JsonNode send(String jsonTemplate) throws Exception { - String requestId = MAPPER.writeValueAsString("access-sync-" + UUID.randomUUID()); - webSocket.sendText(jsonTemplate.formatted(requestId), true).get(8, TimeUnit.SECONDS); - String json = responses.poll(12, TimeUnit.SECONDS); - if (json == null) throw new TimeoutException("WS response timeout"); - return MAPPER.readTree(json); + return ServerConnectionPool.getInstance().request( + serverLogin, + serverAddress, + jsonTemplate, + ServerConnectionPool.Priority.NORMAL); } @Override public void close() { - try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {} - } - - private static final class Listener implements WebSocket.Listener { - private final LinkedBlockingQueue responses; - private final StringBuilder text = new StringBuilder(); - - private Listener(LinkedBlockingQueue responses) { this.responses = responses; } - @Override public void onOpen(WebSocket ws) { ws.request(1); } - @Override public CompletionStage onText(WebSocket ws, CharSequence data, boolean last) { - text.append(data); - if (last) { - responses.offer(text.toString()); - text.setLength(0); - } - ws.request(1); - return CompletableFuture.completedFuture(null); - } - @Override public CompletionStage onBinary(WebSocket ws, ByteBuffer data, boolean last) { - ws.request(1); - return CompletableFuture.completedFuture(null); - } - @Override public void onError(WebSocket ws, Throwable error) { - responses.offer("{\"status\":500,\"code\":\"WS_ERROR\"}"); - } + // Физическое соединение принадлежит 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 3871acab..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,22 @@ 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 { - try (RemoteSyncSession session = new RemoteSyncSession(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); } } @@ -69,7 +64,20 @@ public final class RemoteUserSettingsSyncClient { int limit, int maxBytes ) throws Exception { - try (RemoteSyncSession session = new RemoteSyncSession(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); } } @@ -129,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; @@ -165,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, @@ -194,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/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/PeriodicUserSettingsSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java index b8445b4a..b1fc6ad5 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java @@ -137,7 +137,7 @@ public final class PeriodicUserSettingsSyncService { boolean bootstrapCompleted = false; int appliedDm; - try (RemoteSyncSession session = new RemoteSyncSession(route.getServerUrl())) { + try (RemoteSyncSession session = new RemoteSyncSession(route.getServerLogin(), route.getServerUrl())) { for (int page = 0; page < maxPages; page++) { RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch( session, 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..03f095ac 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -12,6 +12,7 @@ import server.sync.PeriodicDmSyncService; import server.sync.PeriodicUserSettingsSyncService; import server.sync.SolanaUsersSyncStartupService; import server.sync.SyncServersBootstrapService; +import server.sync.ServerConnectionPool; import utils.config.AppConfig; import java.time.Duration; @@ -104,6 +105,10 @@ public final class WsServer { server.start(); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); + ServerConnectionPool.getInstance().startOrLog(); + Runtime.getRuntime().addShutdownHook(new Thread( + () -> ServerConnectionPool.getInstance().close(), + "server-connection-pool-shutdown")); PeriodicDmSyncService.startOrLog(); PeriodicUserSettingsSyncService.startOrLog(); server.join(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 374250c5..990103f7 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -41,6 +41,12 @@ dm.worker.dueLimit=100 dm.sync.batchLimit=200 dm.sync.batchMaxBytes=3000000 dm.sync.maxPagesPerPeer=20 +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/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/09_Operations_Index.md b/docs/API/09_Operations_Index.md index c50f48d0..db86f293 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -32,6 +32,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 всех локальных блокчейнов | @@ -79,6 +80,7 @@ - `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload. - Межсерверные 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/13_User_Settings_API.md b/docs/API/13_User_Settings_API.md index 3cecdacb..15135dac 100644 --- a/docs/API/13_User_Settings_API.md +++ b/docs/API/13_User_Settings_API.md @@ -83,7 +83,10 @@ ## 5. Синхронизация -Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс открывает один последовательный WS-сеанс с peer: сначала синхронизирует настройки, затем забирает `DmSyncBatch`. +Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс +использует один логический последовательный сеанс поверх постоянного WSS-пула: +сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Завершение +логического сеанса не закрывает физический сокет. - локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер; - если запись пришла с другого сервера, она сохраняется сразу как `synced=true`; diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index 79fc7fc8..d951a25a 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -183,16 +183,20 @@ Full resync запускается только тогда, когда: Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. -## 5. Возможное развитие server-to-server транспорта +## 5. Реализованный постоянный server-to-server транспорт -Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth. +Этот раздел не меняет текущую семантику 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) @@ -246,19 +250,21 @@ Full resync запускается только тогда, когда: | Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | | Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | | Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | -| Межсерверный постоянный WebSocket-канал | Нужна реализация | +| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` | | Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано | | Retry DM до 1 часа + UI-state | ✅ Реализовано | | Репликация DM на второй access-сервер по `synced` | ✅ Реализовано | | Read-only `GetDmDeliveryStatus` | ✅ Реализовано | -| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | +| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул | | Periodic backfill отсутствующего хвоста | ✅ Реализовано | | Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | | Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | | Маршрутизация DM через один/два `access_servers` | ✅ Реализовано | | Криптографическая server-to-server авторизация DM | Нужна реализация | -Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов. +Текущая версия сервера использует постоянный WSS-пул для существующих +server-to-server JSON-операций. Отдельной будущей задачей остаётся +криптографическая авторизация server-to-server вызовов. Следующие отдельные шаги после текущего этапа: - отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. From 01321f5200426ce5a4010268d7fb2cafdc2dac26faa36e4345640a8aef8ec09b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 26 Aug 2026 11:11:46 +0400 Subject: [PATCH 04/15] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=87=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=D1=85=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/shine/db/DatabaseInitializer.java | 12 + .../db/dao/BlockchainResyncCleanupDAO.java | 96 +++ .../main/resources/postgres/migration_v14.sql | 620 ++++++++++++++++++ .../main/resources/postgres/migration_v15.sql | 105 +++ .../src/main/resources/postgres/schema_v1.sql | 470 ++++++++++++- .../Net_GetChannelMessages_Handler.java | 27 + .../Net_GetChannelMessages_Response.java | 4 + .../tempToTest/Net_GetUser_Handler.java | 36 + .../entyties/Net_GetUser_Response.java | 16 + .../test/it/cases/IT_03_AddBlock_NoAuth.java | 114 +++- docs/API/01_User_Registration_API.md | 10 +- docs/API/06_Channels_Read_API.md | 1 + docs/Blockchain/CHANGELOG.md | 13 + docs/Blockchain/README.md | 2 + shine-UI/assets/SHiNE_connections_blue.svg | 23 + shine-UI/js/app.js | 3 + shine-UI/js/components/shine-logo.js | 10 + shine-UI/js/components/toolbar.js | 3 +- shine-UI/js/pages/channel-about-view.js | 182 +++++ shine-UI/js/pages/channel-view.js | 12 +- shine-UI/js/pages/channels-list.js | 6 +- shine-UI/js/pages/chat-view.js | 43 +- shine-UI/js/pages/messages-list.js | 10 +- shine-UI/js/pages/profile-view.js | 41 +- shine-UI/js/router.js | 46 +- shine-UI/js/services/call-service.js | 4 +- shine-UI/js/services/shine-routes.js | 20 + shine-UI/styles/components.css | 81 ++- 28 files changed, 1966 insertions(+), 44 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql create mode 100644 shine-UI/assets/SHiNE_connections_blue.svg create mode 100644 shine-UI/js/components/shine-logo.js create mode 100644 shine-UI/js/pages/channel-about-view.js 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 ee5e0430..5bcdc551 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 @@ -30,6 +30,8 @@ public final class DatabaseInitializer { 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 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"; @@ -43,6 +45,8 @@ public final class DatabaseInitializer { 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"; private DatabaseInitializer() {} @@ -154,6 +158,14 @@ public final class DatabaseInitializer { 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; + } } } 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/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/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 102e88f3..b85cf244 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 @@ -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, @@ -605,6 +611,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, @@ -921,6 +953,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 @@ -999,6 +1157,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; @@ -1011,10 +1171,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 @@ -1048,10 +1357,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; @@ -1231,4 +1691,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/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/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/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/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/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/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..516e05c4 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -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/shine-UI/assets/SHiNE_connections_blue.svg b/shine-UI/assets/SHiNE_connections_blue.svg new file mode 100644 index 00000000..65808151 --- /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 + + + + + + + + + + + + + + + + + + ( + String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch + && Number(row?.channel?.channelRoot?.blockNumber) === rootNo + && normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash + )) || null; +} + +function buildChannelLink(route) { + if (!route) return ''; + const url = new URL(window.location.href); + url.pathname = `/${String(route).replace(/^\/+/, '')}`; + url.hash = ''; + return url.toString(); +} + +function statsText(value) { + return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0'; +} + +export function render({ navigate, route, chrome }) { + const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim(); + const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber); + const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash); + const channelRoute = makeShineChannelRootRoute({ + ownerBlockchainName, + channelRootBlockNumber, + channelRootBlockHash, + }); + + const screen = document.createElement('section'); + screen.className = 'stack channels-screen channels-screen--channel-about'; + + const topbar = renderHeader({ + title: 'О канале', + leftAction: { + label: '←', + onClick: () => { + if (window.history.length > 1) { + navigateBack(); + return; + } + if (channelRoute) navigate(channelRoute); + }, + ariaLabel: 'Назад', + title: 'Назад', + }, + }); + chrome?.setTopbar(topbar); + + const card = document.createElement('div'); + card.className = 'card stack channel-about-card'; + card.innerHTML = ` +
+
Загрузка данных канала…
+
+ `; + + const footer = document.createElement('div'); + footer.className = 'meta-muted screen-footer'; + footer.textContent = 'О канале (channel-about-view)'; + + screen.append(card, footer); + + const renderContent = (channel) => { + const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); + const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim(); + const description = String(channel?.channelDescription || channel?.description || '').trim(); + const subscribersCount = Number(channel?.subscribersCount || 0); + const aboutRoute = makeShineChannelRootRoute({ + ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, + channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber, + channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash, + }); + const channelLinkRoute = makeShineChannelShortRoute({ + ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, + channelName: channel?.channelName || '', + }); + const channelLink = buildChannelLink(channelLinkRoute); + const changedAtMs = Number(channel?.metaUpdatedAtMs || 0); + const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—'; + const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен'; + + const content = card.querySelector('#channel-about-content'); + if (!content) return; + content.innerHTML = ` +
+ +
+
+ Дата${escapeHtml(changedAtLabel)} + Владелец${escapeHtml(ownerName)} + Подписчиков${escapeHtml(statsText(subscribersCount))} + Системное имя${escapeHtml(String(channel?.channelName || '').trim() || 'channel')} + Название${escapeHtml(cleanName)} + Описание${escapeHtml(description || 'Описание не задано.')} + Аватар${escapeHtml(avatarState)} + Ссылка${escapeHtml(channelLink)} +
+
+ + +
+ `; + + content.querySelector('#channel-about-open')?.addEventListener('click', () => { + if (!channelLinkRoute) return; + navigate(channelLinkRoute); + }); + content.querySelector('#channel-about-copy')?.addEventListener('click', async () => { + if (!channelLink) return; + try { + await navigator.clipboard.writeText(channelLink); + showToast('Ссылка скопирована'); + } catch (error) { + showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' }); + } + }); + }; + + const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash); + if (cached?.channel) { + renderContent({ + ...cached.channel, + subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0, + }); + return screen; + } + + void (async () => { + try { + const payload = await authService.getChannelMessages({ + ownerBlockchainName, + channelRootBlockNumber, + channelRootBlockHash, + }, 1, 'asc', String(state.session.login || '').trim()); + renderContent(payload?.channel || {}); + } catch (error) { + const content = card.querySelector('#channel-about-content'); + if (content) { + content.innerHTML = ` +
Не удалось загрузить данные канала.
+
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
+ `; + } + } + })(); + + return screen; +} diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 54c6c8bd..363c8ed6 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -30,6 +30,7 @@ import { extractLoginFromBlockchainName, makeProfileRoute, makeShineMessageRoute, + makeShineChannelAboutRoute, } from '../services/shine-routes.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -2550,13 +2551,12 @@ export function render({ navigate, route, chrome }) { channelHeaderButton.disabled = false; channelHeaderButton.onclick = (event) => { animatePress(event.currentTarget); - openAboutChannelModal(apiData.channel, { - canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel), - onEdit: () => openEditChannelModal({ - channel: apiData.channel, - onSave: onEditChannelMeta, - }), + const aboutRoute = makeShineChannelAboutRoute({ + ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', + channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '', + channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '', }); + if (aboutRoute) navigate(aboutRoute); }; } if (channelEntrypointButton) { diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index b48946b7..ce0cf858 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -12,6 +12,7 @@ import { writeChannelNotificationsState, } from '../services/channels-ux.js'; import { makeShineChannelRoute } from '../services/shine-routes.js'; +import { makeShineChannelShortRoute } from '../services/shine-routes.js'; import { renderAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -64,10 +65,9 @@ function buildChannelRouteFromSummary(summary, fallbackId) { const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim(); const ownerLogin = String(summary?.channel?.ownerLogin || '').trim(); const channelName = String(summary?.channel?.channelName || '').trim(); - return makeShineChannelRoute({ - ownerLogin, + return makeShineChannelShortRoute({ ownerBlockchainName: ownerBch, - channelName: channelName || fallbackId, + channelName: channelName || fallbackId || ownerLogin, }); } diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index c0c74328..d9017eba 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,6 +1,7 @@ import { renderHeader } from '../components/header.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; +import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, @@ -30,11 +31,12 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec import { showToast } from '../services/channels-ux.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; -function createChatHeaderParts(login) { +function createChatHeaderParts(login, navigate) { const cleanLogin = String(login || '').trim() || 'unknown'; const avatarSlot = document.createElement('span'); @@ -47,6 +49,13 @@ function createChatHeaderParts(login) { }); avatarSlot.append(initialAvatar); + const avatarButton = document.createElement('button'); + avatarButton.type = 'button'; + avatarButton.className = 'chat-header-avatar-btn'; + avatarButton.title = `Профиль ${cleanLogin}`; + avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`); + avatarButton.append(avatarSlot); + const loginEl = document.createElement('span'); loginEl.className = 'chat-header-login'; loginEl.setAttribute('role', 'heading'); @@ -75,7 +84,23 @@ function createChatHeaderParts(login) { }) .catch(() => {}); - return { centerNode: loginEl, avatarSlot }; + const connectionsButton = document.createElement('button'); + connectionsButton.type = 'button'; + connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn'; + connectionsButton.title = `Связи ${cleanLogin}`; + connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`); + connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' })); + connectionsButton.addEventListener('click', () => { + if (!cleanLogin || cleanLogin === 'unknown') return; + navigate(makeProfileLinksRoute(cleanLogin)); + }); + + avatarButton.addEventListener('click', () => { + if (!cleanLogin || cleanLogin === 'unknown') return; + navigate(makeProfileRoute(cleanLogin)); + }); + + return { centerNode: loginEl, avatarButton, connectionsButton }; } function truncatePreviewText(value, maxLen = 72) { @@ -293,7 +318,6 @@ function openChatActionsMenu({ anchorY = 0, onCall, onVideoCall, - onInstantVideoCall, onClearHistory, onDeleteChat, }) { @@ -305,8 +329,7 @@ function openChatActionsMenu({
@@ -358,10 +381,6 @@ function openChatActionsMenu({ close(); if (typeof onVideoCall === 'function') await onVideoCall(); }); - root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => { - close(); - if (typeof onInstantVideoCall === 'function') await onInstantVideoCall(); - }); root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => { close(); if (typeof onClearHistory === 'function') await onClearHistory(); @@ -993,7 +1012,7 @@ export function render({ navigate, route, chrome }) { const log = document.createElement('div'); log.className = 'messages-log dm-messages-log'; - const chatHeaderParts = createChatHeaderParts(chatId); + const chatHeaderParts = createChatHeaderParts(chatId, navigate); const chatHeader = renderHeader({ centerNode: chatHeaderParts.centerNode, leftAction: { label: '←', onClick: () => navigate('messages-list') }, @@ -1016,7 +1035,6 @@ export function render({ navigate, route, chrome }) { anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0), onCall: () => handleStartCall('audio'), onVideoCall: () => handleStartCall('video'), - onInstantVideoCall: () => handleStartCall('instant_video'), onClearHistory: async () => { openChatConfirmModal({ title: 'Очистить историю?', @@ -1064,7 +1082,8 @@ export function render({ navigate, route, chrome }) { }, ], }); - chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot); + const chatHeaderLeft = chatHeader.querySelector('.header-left'); + chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton); chrome?.setTopbar(chatHeader); if (!isKnownContact) { diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index d52d2916..9aa41db1 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -8,6 +8,7 @@ import { } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; +import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -167,15 +168,11 @@ function compareChatRows(a, b) { export function render({ navigate, chrome }) { const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-list-screen'; - const login = String(state.session.login || '').trim(); const head = document.createElement('header'); head.className = 'dm-head'; head.innerHTML = `
-
${(login[0] || 'A').toUpperCase()}
-
- -
+

Чаты

@@ -191,6 +188,9 @@ export function render({ navigate, chrome }) {
`; + head.querySelector('.dm-head-logo-wrap')?.append( + createShineConnectionsLogo({ className: 'dm-head-logo' }), + ); const menuButton = head.querySelector('.dm-head-menu-btn'); menuButton?.append(createOverflowDots()); diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 231c84bc..8ba70adb 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -1,5 +1,5 @@ import { profile } from '../mock-data.js'; -import { state } from '../state.js'; +import { authService, state } from '../state.js'; import { PROFILE_GENDER_FEMALE, PROFILE_GENDER_MALE, @@ -223,6 +223,12 @@ export function render({ navigate, chrome }) { let currentToggles = []; let currentGender = 'unknown'; let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; + let currentStats = { + ownedPublicChannelsCount: 0, + followingUsersCount: 0, + followingChannelsCount: 0, + closeFriendsCount: 0, + }; function syncIdentity() { if (!identityEl) return; @@ -269,6 +275,21 @@ export function render({ navigate, chrome }) { updateToggleButton(shineBtn, 'Сияющий', shine.enabled); } + function renderStats() { + const stats = [ + { label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount }, + { label: 'Подписки на пользователей', value: currentStats.followingUsersCount }, + { label: 'Подписки на каналы', value: currentStats.followingChannelsCount }, + { label: 'Близкие друзья', value: currentStats.closeFriendsCount }, + ]; + stats.forEach((stat) => { + const row = document.createElement('div'); + row.className = 'card profile-param-item row'; + row.innerHTML = `
${escapeHtml(stat.label)}: ${escapeHtml(String(Number(stat.value || 0)))}
`; + listWrap.append(row); + }); + } + officialBtn?.classList.add('profile-badge-trigger'); shineBtn?.classList.add('profile-badge-trigger'); officialBtn?.addEventListener('click', () => { @@ -286,6 +307,7 @@ export function render({ navigate, chrome }) { function renderFields(fields) { listWrap.innerHTML = ''; + renderStats(); fields.forEach((field) => { const row = document.createElement('div'); row.className = 'card profile-param-item row'; @@ -317,6 +339,12 @@ export function render({ navigate, chrome }) { ]; currentGender = 'unknown'; currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; + currentStats = { + ownedPublicChannelsCount: 0, + followingUsersCount: 0, + followingChannelsCount: 0, + closeFriendsCount: 0, + }; syncIdentity(); updateAvatarUi(); updateTogglesUi(); @@ -325,11 +353,20 @@ export function render({ navigate, chrome }) { } try { - const snapshot = await loadProfileSnapshot(login); + const [snapshot, user] = await Promise.all([ + loadProfileSnapshot(login), + authService.getUser(login).catch(() => ({})), + ]); currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : []; currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : []; currentGender = snapshot.gender || 'unknown'; currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; + currentStats = { + ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0), + followingUsersCount: Number(user?.followingUsersCount || 0), + followingChannelsCount: Number(user?.followingChannelsCount || 0), + closeFriendsCount: Number(user?.closeFriendsCount || 0), + }; syncIdentity(); updateAvatarUi(); updateTogglesUi(); diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index f06cf35f..7323605a 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -26,6 +26,7 @@ const PRETTY_PATHS = new Map([ ['add-channel-view', 'channels/new'], ['add-personal-public-chat-view', 'channels/new-public-chat'], ['channel-view', 'channel'], + ['channel-about-view', 'channel/about'], ['channel-thread-view', 'thread'], ['network-view', 'network'], ['notifications-view', 'notifications'], @@ -52,6 +53,10 @@ const PRETTY_PATHS = new Map([ ['remote-addblock-session-view', 'remote-addblock-session'], ]); +function looksLikeBlockchainName(value) { + return /^.+-\d+$/.test(String(value || '').trim()); +} + export const PRE_AUTH_PAGES = [ 'start-view', 'entry-settings-view', @@ -162,6 +167,34 @@ export function parseRouteFromPath(pathname = '') { } } + if (segments.length >= 2 && looksLikeBlockchainName(segments[0])) { + const ownerBlockchainName = decodePart(segments[0]); + const channelName = decodePart(segments[1] || ''); + const sub = decodePart(segments[2] || '').toLowerCase(); + if (ownerBlockchainName && channelName) { + if (sub === 'about') { + return { + pageId: 'channel-about-view', + params: { + ownerBlockchainName, + channelRootBlockNumber: '', + channelRootBlockHash: '', + channelId: '', + channelName, + }, + }; + } + return { + pageId: 'channel-view', + params: { + ownerBlockchainName, + channelName, + channelId: '', + }, + }; + } + } + if (pageId === 'chat' || pageId === 'chat-view') { return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } }; } @@ -225,6 +258,17 @@ export function parseRouteFromPath(pathname = '') { } if (pageId === 'channel') { + if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') { + return { + pageId: 'channel-about-view', + params: { + ownerBlockchainName: decodePart(segments[1]), + channelRootBlockNumber: segments[2] || '', + channelRootBlockHash: segments[3] || '', + channelId: '', + }, + }; + } if (segments.length >= 4) { return { pageId: 'channel-view', @@ -408,7 +452,7 @@ export function resolveToolbarActive(pageId) { pageId === 'solana-users-init-view' ) return 'profile-view'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; - if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; + if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; if (pageId === 'user') return 'messages-list'; return 'profile-view'; } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index a8eb7afd..b7b77bbe 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -153,7 +153,7 @@ function getCallTitleText(mode) { return 'Видеозвонок'; } if (normalizeCallMode(mode) === CALL_MODE_VIDEO) { - return 'Звонок с поддержкой видео'; + return 'Видеозвонок'; } return 'Звонок'; } @@ -164,7 +164,7 @@ function getIncomingCallStatusText(peerLogin, mode) { return `Входящий видеозвонок от ${name}`; } if (isVideoCallMode(mode)) { - return `Вам звонит ${name} (звонок с поддержкой видео)`; + return `Входящий видеозвонок от ${name}`; } return `Вам звонит ${name}`; } diff --git a/shine-UI/js/services/shine-routes.js b/shine-UI/js/services/shine-routes.js index 7cc605cb..27df45d2 100644 --- a/shine-UI/js/services/shine-routes.js +++ b/shine-UI/js/services/shine-routes.js @@ -51,6 +51,26 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = ' return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; } +export function makeShineChannelRootRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) { + const ownerBch = String(ownerBlockchainName || '').trim(); + const rootNo = String(channelRootBlockNumber || '').trim(); + const rootHash = String(channelRootBlockHash || '').trim(); + if (!ownerBch || !rootNo || !rootHash) return ''; + return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(rootNo)}/${encodeRoutePart(rootHash)}`; +} + +export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) { + const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash }); + return base ? `${base}/about` : ''; +} + +export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) { + const ownerBch = String(ownerBlockchainName || '').trim(); + const chName = String(channelName || '').trim(); + if (!ownerBch || !chName) return ''; + return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; +} + export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) { const msgBch = String(messageBlockchainName || '').trim(); const msgNo = String(messageBlockNumber || '').trim(); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 33590d4a..8b052f45 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -5927,6 +5927,14 @@ textarea.input { transform: translateY(-40%); } +.channels-screen--channel-about .screen-footer { + margin-top: auto; + padding: 8px 4px 0; + text-align: center; + font-size: 12px; + letter-spacing: 0.02em; +} + .entrypoint-history-list { display: grid; gap: 10px; @@ -6184,12 +6192,13 @@ textarea.input { background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0)); } .dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; } -.dm-head-hex { - width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; - font-weight: 700; font-size: 15px; color: #1a1205; - background: linear-gradient(150deg, #F0B82E, #D49F22); - clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); - box-shadow: 0 0 14px rgba(240, 184, 46, 0.35); +.dm-head-logo-wrap { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex: 0 0 auto; } +.dm-head-logo { + width: 36px; + height: 36px; + object-fit: contain; + display: block; + filter: drop-shadow(0 0 7px rgba(71, 196, 255, 0.38)); } .dm-head-id { min-width: 0; display: grid; } .dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -9100,6 +9109,66 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { gap: 0; } +.chat-header-connections-btn, +.chat-header-avatar-btn { + padding: 0; + min-width: 0; + min-height: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; +} + +.chat-header-connections-btn:hover, +.chat-header-connections-btn:active, +.chat-header-connections-btn:focus, +.chat-header-connections-btn:focus-visible, +.chat-header-avatar-btn:hover, +.chat-header-avatar-btn:active, +.chat-header-avatar-btn:focus, +.chat-header-avatar-btn:focus-visible { + border: 0; + background: transparent; + box-shadow: none; + transform: none; +} + +.chat-header-connections-btn { + width: 38px; + height: 38px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(8, 17, 31, 0.65); + border: 1px solid rgba(97, 198, 255, 0.26); + box-shadow: 0 0 0 1px rgba(97, 198, 255, 0.08), 0 0 12px rgba(64, 172, 255, 0.18); +} + +.chat-header-connections-btn:hover, +.chat-header-connections-btn:focus-visible { + border-color: rgba(111, 208, 255, 0.38); + box-shadow: 0 0 0 1px rgba(111, 208, 255, 0.16), 0 0 16px rgba(64, 172, 255, 0.28); +} + +.chat-header-connections-logo { + width: 24px; + height: 24px; + object-fit: contain; + display: block; +} + +.chat-header-avatar-btn { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chat-header-avatar-btn .chat-header-avatar-slot { + display: inline-flex; +} + /* Пузырям сообщений не нужна отдельная обводка: направление читается по фону и геометрии. */ .dm-chat-screen .bubble, .dm-chat-screen .bubble.in, From 83f87ba22c56cddb78b31ff42eebb3d142b1acb057b78fd8125a5ca1b170bccc Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 26 Aug 2026 12:05:42 +0400 Subject: [PATCH 05/15] =?UTF-8?q?=D0=92=D0=B5=D1=80=D0=BD=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=BE=D1=82=D0=B8=D0=BF=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20SVG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/assets/SHiNE_connections_blue.svg | 2 +- shine-UI/js/components/shine-logo.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shine-UI/assets/SHiNE_connections_blue.svg b/shine-UI/assets/SHiNE_connections_blue.svg index 65808151..7d9f65fb 100644 --- a/shine-UI/assets/SHiNE_connections_blue.svg +++ b/shine-UI/assets/SHiNE_connections_blue.svg @@ -18,6 +18,6 @@ - diff --git a/shine-UI/js/components/shine-logo.js b/shine-UI/js/components/shine-logo.js index 0c2460c6..4c1c27c9 100644 --- a/shine-UI/js/components/shine-logo.js +++ b/shine-UI/js/components/shine-logo.js @@ -1,4 +1,4 @@ -export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg'; +export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg?v=2026082601'; export function createShineConnectionsLogo({ className = '' } = {}) { const img = document.createElement('img'); From 2a65e6d359990f1e3c04119ffe331ea4736d33cd5468b7de57d707461bf6a8ea Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 26 Aug 2026 12:33:18 +0400 Subject: [PATCH 06/15] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=D1=8B=20=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE=D0=B2=20=D0=B2=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/components/avatar-image.js | 11 +- shine-UI/js/pages/add-channel-view.js | 4 +- shine-UI/js/pages/channel-thread-view.js | 4 +- shine-UI/js/pages/channel-view.js | 10 +- shine-UI/js/pages/channels-list.js | 2 +- shine-UI/js/pages/chat-view.js | 4 +- shine-UI/js/pages/contact-search-view.js | 4 +- shine-UI/js/pages/messages-list.js | 4 +- shine-UI/js/pages/notifications-view.js | 2 +- shine-UI/js/pages/profile-edit-view.js | 2 +- shine-UI/js/pages/profile-view.js | 2 +- shine-UI/js/pages/user-profile-view.js | 2 +- shine-UI/js/services/call-ui-service.js | 2 +- shine-UI/styles/components.css | 163 +++++++++++++++-------- 15 files changed, 134 insertions(+), 84 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index c16c29a1..ec63d844 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.7.0 +client.version=1.7.1 server.version=1.6.1 diff --git a/shine-UI/js/components/avatar-image.js b/shine-UI/js/components/avatar-image.js index 88393acc..f4e677bc 100644 --- a/shine-UI/js/components/avatar-image.js +++ b/shine-UI/js/components/avatar-image.js @@ -8,9 +8,12 @@ function normalizeLogin(value) { function pickSizeClass(size) { const raw = String(size || '').trim().toLowerCase(); - if (raw === 'large') return 'large'; if (raw === 'node') return 'node-dot'; - if (raw === 'small') return ''; + if (raw === 'xs' || raw === 'xsmall' || raw === 'tiny') return 'avatar-xs'; + if (raw === 'sm' || raw === 'small') return 'avatar-sm'; + if (raw === 'md' || raw === 'medium' || raw === '') return 'avatar-md'; + if (raw === 'lg' || raw === 'big') return 'avatar-lg'; + if (raw === 'xl' || raw === 'xlarge' || raw === 'large' || raw === 'very-large') return 'avatar-xl'; return raw || ''; } @@ -28,7 +31,7 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = { export function renderAvatar({ initials = '?', avatar = null, - size = 'large', + size = 'xl', className = '', title = '', alt = 'Аватар', @@ -139,7 +142,7 @@ export function renderUserAvatar({ firstName = '', lastName = '', avatar = null, - size = 'large', + size = 'xl', className = '', title = '', glow = false, diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index 2e399f62..c669341d 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -44,12 +44,12 @@ function renderAvatarPreview(slot, avatar, title) { const wrap = renderAvatar({ initials: label.slice(0, 1).toUpperCase() || 'К', avatar, - size: 'small', + size: 'xl', className: 'channel-profile-avatar', title: label, alt: 'Аватар канала', }); - wrap.style.setProperty('--channel-avatar-size', '104px'); + wrap.style.setProperty('--channel-avatar-size', '96px'); slot.append(wrap); } diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index ab844774..a4082ea5 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -62,7 +62,7 @@ function createThreadAvatar(login) { const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', - size: 'small', + size: 'sm', className: 'channel-message-avatar', title, }); @@ -77,7 +77,7 @@ function createThreadAvatar(login) { sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, - size: 'small', + size: 'sm', className: 'channel-message-avatar', title, }); diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 363c8ed6..8baa95fa 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -86,7 +86,7 @@ function createMessageAvatar(login) { const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', - size: 'small', + size: 'sm', className: 'channel-message-avatar', title, }); @@ -101,7 +101,7 @@ function createMessageAvatar(login) { sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, - size: 'small', + size: 'sm', className: 'channel-message-avatar', title, }); @@ -577,13 +577,13 @@ function getStatusActionOptionsForTarget(targetMsgSubType) { } } -function createChannelAvatarElement(channel, size = 72) { +function createChannelAvatarElement(channel, size = 96) { const txId = String(channel?.avaAr || '').trim(); const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К'; const wrap = renderAvatar({ initials: title.slice(0, 1).toUpperCase() || 'К', avatar: txId ? { ar: txId } : null, - size: 'small', + size: 'xl', className: 'channel-profile-avatar', title, alt: 'Аватар канала', @@ -626,7 +626,7 @@ function openChannelMetaDetailsModal({ `; - root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86)); + root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 96)); root.querySelector('#about-channel-close')?.addEventListener('click', () => { root.innerHTML = ''; diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index ce0cf858..fd636575 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -94,7 +94,7 @@ function createChannelAvatar(channel = {}) { return renderAvatar({ initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name), avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null, - size: 'small', + size: 'md', title: channel.displayTitle || channel.title || channel.name || 'Канал', alt: 'Аватар канала', }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index d9017eba..bb196e21 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -43,7 +43,7 @@ function createChatHeaderParts(login, navigate) { avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left'; const initialAvatar = renderUserAvatar({ login: cleanLogin, - size: 'small', + size: 'md', className: 'chat-header-avatar', title: cleanLogin, }); @@ -76,7 +76,7 @@ function createChatHeaderParts(login, navigate) { sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, - size: 'small', + size: 'md', className: 'chat-header-avatar', title: cleanLogin, }); diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js index 39af69e0..f423f6dd 100644 --- a/shine-UI/js/pages/contact-search-view.js +++ b/shine-UI/js/pages/contact-search-view.js @@ -34,7 +34,7 @@ function createSearchAvatar(login) { const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', - size: 'small', + size: 'md', className: 'avatar', title, }); @@ -49,7 +49,7 @@ function createSearchAvatar(login) { sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, - size: 'small', + size: 'md', className: 'avatar', title, }); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 9aa41db1..787e5855 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -52,7 +52,7 @@ function createDmAvatar(login, { className = '' } = {}) { const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', - size: 'small', + size: 'md', title, className, }); @@ -67,7 +67,7 @@ function createDmAvatar(login, { className = '' } = {}) { sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, - size: 'small', + size: 'md', title, className, }); diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index e6fb6f62..31a73941 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -165,7 +165,7 @@ function renderIdentity(item) { firstName, lastName, avatar, - size: 'small', + size: 'md', className: 'notification-avatar', })); diff --git a/shine-UI/js/pages/profile-edit-view.js b/shine-UI/js/pages/profile-edit-view.js index fad1994e..54d5b9d8 100644 --- a/shine-UI/js/pages/profile-edit-view.js +++ b/shine-UI/js/pages/profile-edit-view.js @@ -229,7 +229,7 @@ export function render({ navigate, chrome }) { avatar: currentAvatar?.txId ? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() } : null, - size: 'large', + size: 'xl', className: 'profile-avatar', })); } diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 8ba70adb..747cf990 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -252,7 +252,7 @@ export function render({ navigate, chrome }) { avatar: currentAvatar?.txId ? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() } : null, - size: 'large', + size: 'xl', className: 'profile-avatar', })); } diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index 32369511..8e5ac022 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -156,7 +156,7 @@ function renderIdentity(card) { firstName: card.firstName, lastName: card.lastName, avatar: card.avatar, - size: 'large', + size: 'xl', className: 'profile-avatar', })); diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js index 219fe9f6..fcccc286 100644 --- a/shine-UI/js/services/call-ui-service.js +++ b/shine-UI/js/services/call-ui-service.js @@ -114,7 +114,7 @@ function renderPeerAvatar(login, avatar = null) { const avatarEl = renderUserAvatar({ login: String(login || '').trim() || 'unknown', avatar, - size: 'large', + size: 'xl', className: 'call-peer-avatar', title: login ? `Профиль ${login}` : 'Профиль собеседника', }); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 8b052f45..f1fa9204 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1430,10 +1430,10 @@ } .avatar { - width: 44px; - height: 44px; - min-width: 44px; - min-height: 44px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; flex: 0 0 auto; aspect-ratio: 1 / 1; border-radius: 50%; @@ -1442,11 +1442,52 @@ place-items: center; font-weight: 700; color: #e5ebff; + font-size: 14px; } -.avatar.large { - width: 82px; - height: 82px; +.avatar.avatar-xs, +.avatar.xs { + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + font-size: 9px; +} + +.avatar.avatar-sm, +.avatar.small { + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + font-size: 10px; +} + +.avatar.avatar-md, +.avatar.medium { + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + font-size: 14px; +} + +.avatar.avatar-lg, +.avatar.big { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + font-size: 18px; +} + +.avatar.avatar-xl, +.avatar.large, +.avatar.xlarge { + width: 96px; + height: 96px; + min-width: 96px; + min-height: 96px; font-size: 26px; } @@ -4143,10 +4184,10 @@ textarea.input { } .channel-row .avatar { - width: 58px; - height: 58px; - min-width: 58px; - min-height: 58px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; border-radius: 50%; background: linear-gradient(138deg, #efd193, #c18c42); color: #17233f; @@ -4390,15 +4431,15 @@ textarea.input { } .channel-message-avatar { - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; - font-size: 16px; + font-size: 10px; font-weight: 700; color: #f4f6ff; background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89); @@ -5010,10 +5051,10 @@ textarea.input { } .channel-row .avatar { - width: 64px; - height: 64px; - min-width: 64px; - min-height: 64px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; } .channel-row-title { @@ -5754,10 +5795,10 @@ textarea.input { } .channel-row .avatar { - width: 68px; - height: 68px; - min-width: 68px; - min-height: 68px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; border-radius: 50%; overflow: hidden; color: #ffffff; @@ -6155,10 +6196,10 @@ textarea.input { .dm-card--shining { border-color: rgba(104, 216, 255, 0.45); } /* линия связи: cyan (сияющий) */ .dm-screen .list-item .avatar { - width: 48px; - height: 48px; - min-width: 48px; - min-height: 48px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; border-radius: 50%; border: 1px solid rgba(212, 175, 55, 0.45); background: @@ -6255,8 +6296,8 @@ textarea.input { .dm-name-check svg { width: 16px; height: 16px; } /* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */ -.dm-av { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; position: relative; } -.dm-av .avatar { width: 50px; height: 50px; min-width: 50px; min-height: 50px; border: none; box-shadow: none; } +.dm-av { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; position: relative; } +.dm-av .avatar { width: 40px; height: 40px; min-width: 40px; min-height: 40px; border: none; box-shadow: none; } /* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */ .dm-av--default { box-shadow: none; } .dm-av--family { box-shadow: none; } @@ -7196,6 +7237,7 @@ html, body { overflow-x: hidden; } /* ===== Targeted UI touchups (requested) ===== */ .channels-screen--list .channel-row .avatar, +.profile-screen .avatar-xl, .profile-screen .avatar.large { position: relative; border-radius: 50%; @@ -7212,6 +7254,7 @@ html, body { overflow-x: hidden; } } .channels-screen--list .channel-row .avatar::after, +.profile-screen .avatar-xl::after, .profile-screen .avatar.large::after { content: ""; position: absolute; @@ -8156,6 +8199,10 @@ html, body { overflow-x: hidden; } } .notification-avatar { + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; flex: 0 0 auto; } @@ -8481,10 +8528,10 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { } .chat-header-avatar-slot { - width: 28px; - height: 28px; - min-width: 28px; - min-height: 28px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; display: inline-flex; align-items: center; justify-content: center; @@ -8492,11 +8539,11 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { .chat-header-avatar.avatar, .chat-header-avatar.avatar-image { - width: 28px; - height: 28px; - min-width: 28px; - min-height: 28px; - font-size: 11px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + font-size: 14px; border: 0; box-shadow: none; } @@ -8989,10 +9036,10 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { /* «Личные»: вместо жёлтого шестиугольника — текущий аватар пользователя. */ .dm-head-avatar-slot { - width: 38px; - height: 38px; - min-width: 38px; - min-height: 38px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; display: inline-flex; align-items: center; justify-content: center; @@ -9000,11 +9047,11 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { .dm-head-avatar.avatar, .dm-head-avatar.avatar-image { - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - font-size: 12px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + font-size: 14px; } /* Список личных чатов: без цветной границы карточки, компактная ава как в шапке @@ -9036,11 +9083,11 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { .dm-list-screen .dm-av .avatar, .dm-list-screen .list-item .avatar { - width: 28px; - height: 28px; - min-width: 28px; - min-height: 28px; - font-size: 10px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + font-size: 14px; border: 0; box-shadow: none; } @@ -9242,15 +9289,15 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { .dm-head-avatar-slot, .dm-head-avatar.avatar, .dm-head-avatar.avatar-image { - width: 28px; - height: 28px; - min-width: 28px; - min-height: 28px; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; } .dm-head-avatar.avatar, .dm-head-avatar.avatar-image { - font-size: 11px; + font-size: 14px; } /* Composer должен быть продолжением основного фона: без верхнего разделителя From f6b95d94bc99132b39bbfc340b5cb88d135dd4c0fc6e31211806ba75e577d9f1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 26 Aug 2026 12:39:49 +0400 Subject: [PATCH 07/15] =?UTF-8?q?=D0=A3=D0=B2=D0=B5=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=D1=85=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/channels-list.js | 2 +- shine-UI/js/pages/messages-list.js | 4 ++-- shine-UI/styles/components.css | 31 ++++++++++++++++++------------ 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index ec63d844..6b9c2f32 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.7.1 +client.version=1.7.2 server.version=1.6.1 diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index fd636575..f6eee5fc 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -94,7 +94,7 @@ function createChannelAvatar(channel = {}) { return renderAvatar({ initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name), avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null, - size: 'md', + size: 'lg', title: channel.displayTitle || channel.title || channel.name || 'Канал', alt: 'Аватар канала', }); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 787e5855..b03ade88 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -52,7 +52,7 @@ function createDmAvatar(login, { className = '' } = {}) { const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', - size: 'md', + size: 'lg', title, className, }); @@ -67,7 +67,7 @@ function createDmAvatar(login, { className = '' } = {}) { sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, - size: 'md', + size: 'lg', title, className, }); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index f1fa9204..f8830858 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -8006,6 +8006,13 @@ html, body { overflow-x: hidden; } box-shadow: none; } +.channels-screen--list .channel-row .avatar { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; +} + .channels-screen--list .channel-row-main { gap: 3px; } @@ -9055,9 +9062,9 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { } /* Список личных чатов: без цветной границы карточки, компактная ава как в шапке - * самого чата (28px), а дата/время стоит на строке превью. */ + * самого чата, а дата/время стоит на строке превью. */ .dm-list-screen .dm-dialog-card { - grid-template-columns: 38px minmax(0, 1fr) auto; + grid-template-columns: 60px minmax(0, 1fr) auto; gap: 10px; min-height: 58px; padding: 8px 10px; @@ -9075,19 +9082,19 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { } .dm-list-screen .dm-av { - width: 34px; - height: 34px; - min-width: 34px; - min-height: 34px; + width: 60px; + height: 60px; + min-width: 60px; + min-height: 60px; } .dm-list-screen .dm-av .avatar, .dm-list-screen .list-item .avatar { - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; - font-size: 14px; + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + font-size: 18px; border: 0; box-shadow: none; } @@ -9141,7 +9148,7 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { @media (max-width: 380px) { .dm-list-screen .dm-dialog-card { - grid-template-columns: 38px minmax(0, 1fr) auto; + grid-template-columns: 60px minmax(0, 1fr) auto; } .dm-list-screen .dm-row-meta-col { From ef1fd9f579b0fa95c3fbfeae6bf58d1ed565390016eeee889148b45c61dada33 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 28 Aug 2026 12:55:28 +0400 Subject: [PATCH 08/15] =?UTF-8?q?=D0=97=D0=B0=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=D0=B8=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B8=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20UI=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- docs/Personal_Messages/Протокол_DM_v1.md | 12 ++ docs/Personal_Messages/Формат_DM_v1.md | 6 + shine-UI/assets/profile-icon-settings.svg | 7 +- shine-UI/index.html | 2 +- shine-UI/js/app.js | 8 +- shine-UI/js/pages/channels-list.js | 39 +++- shine-UI/js/pages/chat-view.js | 117 ++++++++--- shine-UI/js/pages/messages-list.js | 43 +++- shine-UI/styles/buttons-white.css | 95 +++++++++ shine-UI/styles/components.css | 240 ++++++++++++++++++++++ shine-UI/styles/main.css | 5 + 12 files changed, 522 insertions(+), 54 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 6b9c2f32..20b9c72a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.7.2 +client.version=1.7.3 server.version=1.6.1 diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 9aa8d277..9b457dcb 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -682,3 +682,15 @@ Legacy-таблица `dm_sync_peer_state` после миграции v12 фи - ротация `clientKey`; - финальная конкретная UI-реализация массовой перешифровки; - межсерверная авторизация DM-синхронизации и доставки. + +## 15. UI списка диалогов и загрузки истории + +Актуальные правила клиентского интерфейса: + +- превью диалога выбирается по самому новому контентному сообщению независимо от направления `in/out`; +- если локально уже есть более новое расшифрованное сообщение, чем в серверной сводке `ListContacts`, клиент использует локальный текст и время до следующего согласования с сервером; +- локальное сообщение сравнивается с `lastMessageTimeMs`, поэтому входящие и исходящие сообщения участвуют в выборе на равных; +- индикатор загрузки истории всегда является компактным элементом фиксированной высоты сверху списка и не растягивается на высоту пустого чата; +- клик по аватару или логину собеседника открывает меню перехода в профиль или связи этого пользователя. + +Эти правила относятся только к UI и не изменяют сетевой или байтовый формат DM v1. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 14d6ddb3..dcb51ef0 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -341,3 +341,9 @@ ReadReceiptBody_v1_0 В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. Межсерверные операции `ReceiveOutcomingMessage`, `ReceiveIncomingMessage` и `DmSyncBatch` не вводят новый байтовый формат DM. Они передают уже сохранённые raw-контейнеры `SHiNE_DM` в Base64. `ackSyncIds` подтверждает только факт сохранения синхронизированной ревизии; delivery-state между серверами не передаётся. Принимающий сервер заново проверяет подпись и применяет контейнер по его `messageType`. + +## 14. Превью в списке диалогов + +Выбор последнего сообщения для клиентского превью не является частью байтового формата. Клиент сравнивает время сообщений обоих направлений и показывает текст самого нового входящего или исходящего сообщения. Локальная расшифрованная копия может временно иметь приоритет над более старым `lastMessageBlobB64` из серверной сводки. + +Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются. diff --git a/shine-UI/assets/profile-icon-settings.svg b/shine-UI/assets/profile-icon-settings.svg index c3bd6471..160fcc17 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/index.html b/shine-UI/index.html index 6a0bf3a9..b785dcf4 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -12,7 +12,7 @@ СИЯНИЕ ${item.label}`; btn.addEventListener('click', () => { closeTopChannelsMenu(listState); item.action?.(); @@ -1054,8 +1067,11 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi actionBtn.type = 'button'; actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim(); + const actionLabel = document.createElement('span'); + actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel); + if (canToggleSubscription) { - actionBtn.textContent = channel.pending + actionLabel.textContent = channel.pending ? 'Выполняется...' : channel.isSubscribed ? 'Отписаться' @@ -1075,7 +1091,7 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi channel.pending = true; actionBtn.disabled = true; - actionBtn.textContent = 'Выполняется...'; + actionLabel.textContent = 'Выполняется...'; const nextSubscribed = !channel.isSubscribed; try { @@ -1097,13 +1113,13 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi } catch (error) { channel.pending = false; actionBtn.disabled = false; - actionBtn.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться'; + actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться'; showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' }); rerenderList(); } }); } else { - actionBtn.textContent = 'Собственный канал'; + actionLabel.textContent = 'Собственный канал'; actionBtn.disabled = true; } @@ -1111,7 +1127,8 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi toggleWrap.className = 'channel-menu-toggle'; const toggleLabel = document.createElement('span'); - toggleLabel.textContent = 'Уведомления'; + toggleLabel.className = 'channel-menu-toggle-label'; + toggleLabel.innerHTML = `${channelMenuIcon('notifications')}Уведомления`; const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index bb196e21..5ca75ded 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,7 +1,6 @@ import { renderHeader } from '../components/header.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; -import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, @@ -36,6 +35,72 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route export const pageMeta = { id: 'chat-view', title: 'Чат' }; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; +function menuIconSvg(name) { + const paths = { + reply: '', + copy: '', + read: '', + edit: '', + delete: '', + call: '', + video: '', + clear: '', + }; + return ``; +} + +function openUserIdentityMenu({ anchorEl, login, navigate }) { + const root = document.getElementById('modal-root'); + if (!root || !anchorEl) return; + const cleanLogin = String(login || '').trim(); + root.innerHTML = ` +
+ +
+ `; + + const layer = root.querySelector('#chat-user-menu-layer'); + const menu = root.querySelector('.dm-user-identity-menu'); + const close = () => { + document.removeEventListener('keydown', onKeydown); + root.innerHTML = ''; + }; + const onKeydown = (event) => { + if (event.key === 'Escape') close(); + }; + const rect = anchorEl.getBoundingClientRect(); + + window.requestAnimationFrame(() => { + if (!menu) return; + const width = menu.offsetWidth || 190; + const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left)); + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(rect.bottom + 7)}px`; + }); + + layer?.addEventListener('pointerdown', (event) => { + if (event.target === layer) close(); + }); + document.addEventListener('keydown', onKeydown); + root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => { + close(); + navigate(makeProfileLinksRoute(cleanLogin)); + }); + root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => { + close(); + navigate(makeProfileRoute(cleanLogin)); + }); +} + function createChatHeaderParts(login, navigate) { const cleanLogin = String(login || '').trim() || 'unknown'; @@ -52,12 +117,13 @@ function createChatHeaderParts(login, navigate) { const avatarButton = document.createElement('button'); avatarButton.type = 'button'; avatarButton.className = 'chat-header-avatar-btn'; - avatarButton.title = `Профиль ${cleanLogin}`; - avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`); + avatarButton.title = `Меню ${cleanLogin}`; + avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`); avatarButton.append(avatarSlot); - const loginEl = document.createElement('span'); - loginEl.className = 'chat-header-login'; + const loginEl = document.createElement('button'); + loginEl.type = 'button'; + loginEl.className = 'chat-header-login chat-header-login-btn'; loginEl.setAttribute('role', 'heading'); loginEl.setAttribute('aria-level', '1'); loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`); @@ -84,23 +150,14 @@ function createChatHeaderParts(login, navigate) { }) .catch(() => {}); - const connectionsButton = document.createElement('button'); - connectionsButton.type = 'button'; - connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn'; - connectionsButton.title = `Связи ${cleanLogin}`; - connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`); - connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' })); - connectionsButton.addEventListener('click', () => { + const openMenu = (event) => { if (!cleanLogin || cleanLogin === 'unknown') return; - navigate(makeProfileLinksRoute(cleanLogin)); - }); + openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate }); + }; + avatarButton.addEventListener('click', openMenu); + loginEl.addEventListener('click', openMenu); - avatarButton.addEventListener('click', () => { - if (!cleanLogin || cleanLogin === 'unknown') return; - navigate(makeProfileRoute(cleanLogin)); - }); - - return { centerNode: loginEl, avatarButton, connectionsButton }; + return { centerNode: loginEl, avatarButton }; } function truncatePreviewText(value, maxLen = 72) { @@ -232,11 +289,11 @@ function openMessageActionsMenu({ root.innerHTML = `
@@ -328,10 +385,10 @@ function openChatActionsMenu({ root.innerHTML = `
`; @@ -1083,7 +1140,7 @@ export function render({ navigate, route, chrome }) { ], }); const chatHeaderLeft = chatHeader.querySelector('.header-left'); - chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton); + chatHeaderLeft?.append(chatHeaderParts.avatarButton); chrome?.setTopbar(chatHeader); if (!isKnownContact) { diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index b03ade88..ba436d76 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -1,5 +1,6 @@ import { authService, + getChatMessages, isSessionInvalidError, normalizeDmChatId, setContacts, @@ -106,6 +107,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) { } async function resolveDialogPreview(dialog) { + const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim()); + if (localText) return localText; const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim(); if (!blobB64) return 'Диалог пока пуст.'; @@ -145,6 +148,29 @@ async function resolveDialogPreview(dialog) { return pending; } +function resolveLocalMessageTimeMs(message) { + const keys = [message?.baseKey, message?.messageKey]; + for (const key of keys) { + const parts = String(key || '').split('|'); + const timeMs = Number(parts[2] || 0); + if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs; + } + const tempParts = String(message?.tempId || '').split('-'); + const tempTimeMs = Number(tempParts[1] || 0); + if (tempParts[0] === 'tmp' && Number.isFinite(tempTimeMs) && tempTimeMs > 0) return tempTimeMs; + const createdAtMs = Number(message?.createdAtMs || message?.ts || 0); + return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : 0; +} + +function latestLocalDialogMessage(peerLogin) { + const messages = getChatMessages(peerLogin); + const latest = [...messages].sort((a, b) => resolveLocalMessageTimeMs(b) - resolveLocalMessageTimeMs(a))[0]; + if (!latest) return null; + const parsed = parseDmTechBlocks(String(latest?.text || '')); + const text = clipPreviewText(String(parsed.displayText || parsed.visibleText || '').trim()) || 'Сообщение'; + return { text, timeMs: resolveLocalMessageTimeMs(latest) }; +} + function formatChatRowTime(ts) { const value = Number(ts || 0); if (!Number.isFinite(value) || value <= 0) return ''; @@ -350,16 +376,23 @@ function renderRow(item) { unreadCount: Number(dialog?.unreadCount || 0), hasDialog: Boolean(dialog?.hasDialog), }; + const localLatest = latestLocalDialogMessage(peerLogin); + if (localLatest && localLatest.timeMs >= next.lastMessageTimeMs) { + next.lastMessageText = localLatest.text; + next.lastMessageTimeMs = localLatest.timeMs; + } const current = byPeer.get(key); if (!current) { byPeer.set(key, next); return; } - const currentRank = relationOrder(current.relationFlag); - const nextRank = relationOrder(relationFlag); - if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) { - byPeer.set(key, next); - } + const newest = next.lastMessageTimeMs > current.lastMessageTimeMs ? next : current; + newest.relationFlag = relationOrder(relationFlag) < relationOrder(current.relationFlag) + ? relationFlag + : current.relationFlag; + newest.unreadCount = Math.max(Number(current.unreadCount || 0), Number(next.unreadCount || 0)); + newest.hasDialog = Boolean(current.hasDialog || next.hasDialog); + byPeer.set(key, newest); }); const rows = Array.from(byPeer.values()).sort((a, b) => { diff --git a/shine-UI/styles/buttons-white.css b/shine-UI/styles/buttons-white.css index 85f3b4f8..a7e0749c 100644 --- a/shine-UI/styles/buttons-white.css +++ b/shine-UI/styles/buttons-white.css @@ -221,3 +221,98 @@ 0 0 12px rgba(72, 145, 255, 0.34) !important; filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important; } + +/* Второй общий тип кнопок. Цвет меняется одной переменной --shine-action-blue + * в main.css. Экран настройки сервера намеренно не получает этот класс. */ +:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn), +:root .screen-content.filled-action-buttons a.primary-btn, +:root .screen-content.filled-action-buttons a.secondary-btn, +:root .screen-content.filled-action-buttons a.ghost-btn, +:root .screen-content.filled-action-buttons a.text-btn { + color: #ffffff !important; + background: var(--shine-action-blue) !important; + background-image: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,0)) !important; + border: 0 !important; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.24), + 0 8px 22px rgba(var(--shine-action-blue-rgb), .24) !important; + text-shadow: 0 1px 2px rgba(0,0,0,.18) !important; + border-radius: 14px; +} + +:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):hover, +:root .screen-content.filled-action-buttons a.primary-btn:hover, +:root .screen-content.filled-action-buttons a.secondary-btn:hover, +:root .screen-content.filled-action-buttons a.ghost-btn:hover, +:root .screen-content.filled-action-buttons a.text-btn:hover { + background: var(--shine-action-blue-hover) !important; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.28), + 0 10px 26px rgba(var(--shine-action-blue-rgb), .32) !important; +} + +:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):active, +:root .screen-content.filled-action-buttons a.primary-btn:active, +:root .screen-content.filled-action-buttons a.secondary-btn:active, +:root .screen-content.filled-action-buttons a.ghost-btn:active, +:root .screen-content.filled-action-buttons a.text-btn:active { + background: var(--shine-action-blue-pressed) !important; + box-shadow: inset 0 3px 8px rgba(0,0,0,.26) !important; +} + +:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):disabled { + color: rgba(255,255,255,.58) !important; + background: rgba(var(--shine-action-blue-rgb), .42) !important; + box-shadow: none !important; +} + +/* Специальная системная кнопка должна сохранять синюю круглую подложку, + * несмотря на глобальный borderless-reset выше. */ +:root button.scroll-to-bottom-btn, +:root button.scroll-to-bottom-btn:hover, +:root button.scroll-to-bottom-btn:focus, +:root button.scroll-to-bottom-btn:focus-visible { + color: #ffffff !important; + background: var(--shine-action-blue) !important; + border: 0 !important; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.25), + 0 10px 26px rgba(var(--shine-action-blue-rgb), .34) !important; + filter: none !important; +} + +:root button.scroll-to-bottom-btn:hover { + background: var(--shine-action-blue-hover) !important; +} + +:root button.scroll-to-bottom-btn:active { + background: var(--shine-action-blue-pressed) !important; + box-shadow: inset 0 3px 9px rgba(0,0,0,.28) !important; +} + +/* Пункты унифицированных выпадающих меню остаются без постоянной заливки, + * но получают общую синюю реакцию при наведении/фокусе. */ +:root button.dm-head-menu-item, +:root button.channel-menu-item, +:root button.dm-message-action-btn { + justify-content: flex-start !important; + color: #f4f8ff !important; + background: transparent !important; + text-align: left !important; +} + +:root button.dm-head-menu-item:hover, +:root button.dm-head-menu-item:focus-visible, +:root button.channel-menu-item:hover, +:root button.channel-menu-item:focus-visible, +:root button.dm-message-action-btn:hover, +:root button.dm-message-action-btn:focus-visible { + background: rgba(var(--shine-action-blue-rgb), .12) !important; + box-shadow: none !important; + transform: none !important; +} + +:root button.channel-menu-item.destructive, +:root button.dm-message-action-btn--danger { + color: #ffb7c5 !important; +} diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index f8830858..1a12ac35 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -9695,3 +9695,243 @@ body.chat-topbar-overlay .composer-slot > * { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } + +/* ===== Единая полировка меню, DM и управляющих кнопок (2026-08-26) ===== */ + +/* Центральная вкладка остаётся на прежней оси иконок, но имеет большую + * интерактивную область; подпись опущена ниже и больше не заходит на логотип. */ +.toolbar-btn-network { + width: calc(100% + 6px); + min-height: 58px; + margin-inline: -3px; +} + +.toolbar-btn-network > span:last-child { + display: block; + position: relative; + transform: translateY(7px); +} + +/* Общая круглая кнопка прокрутки. Её цвет задаётся --shine-action-blue. */ +.scroll-to-bottom-btn { + width: 54px; + height: 54px; + right: 16px; + border-radius: 50%; +} + +.scroll-to-bottom-btn__icon { + font-size: 28px; + font-weight: 700; + transform: translateY(-2px); +} + +/* Один визуальный язык для меню профиля, чатов и каналов. */ +.dm-head-menu, +.channel-menu-wrap, +.dm-message-actions-menu { + min-width: 196px; + padding: 6px; + gap: 2px; + border: 1px solid rgba(92, 190, 255, 0.22); + border-radius: 16px; + background: linear-gradient(155deg, rgba(19, 27, 42, 0.97), rgba(7, 12, 22, 0.98)); + box-shadow: + 0 18px 42px rgba(0, 0, 0, 0.48), + 0 0 24px rgba(39, 141, 255, 0.10), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + backdrop-filter: blur(20px) saturate(125%); + -webkit-backdrop-filter: blur(20px) saturate(125%); +} + +/* У меню больше нет «облачного» треугольного хвостика. */ +.dm-head-menu::before { + display: none !important; + content: none !important; +} + +.dm-head-menu-item, +.channel-menu-item, +.dm-message-action-btn { + width: 100%; + min-height: 42px; + padding: 0 11px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: #f4f8ff; + font: inherit; + font-size: 14px; + font-weight: 600; + line-height: 1.2; + text-align: left; + white-space: nowrap; +} + +.dm-head-menu-item:hover, +.dm-head-menu-item:focus-visible, +.channel-menu-item:hover, +.channel-menu-item:focus-visible, +.dm-message-action-btn:hover, +.dm-message-action-btn:focus-visible { + outline: none; + background: rgba(39, 141, 255, 0.12); +} + +.dm-head-menu-item svg, +.dm-head-menu-item img, +.channel-menu-icon, +.dm-menu-icon, +.dm-menu-image-icon { + width: 18px; + height: 18px; + min-width: 18px; + flex: 0 0 18px; + object-fit: contain; + color: #65b7ff; + filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28)); +} + +.profile-head-menu .profile-head-menu-item img { + width: 17px; + height: 17px; + min-width: 17px; + flex-basis: 17px; +} + +.channel-menu-wrap--portal { + position: fixed; +} + +.channel-menu-divider { + margin: 4px 8px !important; + background: rgba(255, 255, 255, 0.08) !important; +} + +.channel-menu-item.destructive, +.dm-message-action-btn--danger { + color: #ffb7c5; + background: transparent; +} + +.channel-menu-toggle { + min-height: 42px; + padding: 0 11px; + border-radius: 10px; +} + +.channel-menu-toggle-label { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.dm-message-actions-menu { + width: min(78vw, 240px); +} + +.dm-user-identity-menu { + width: 196px; +} + +.dm-user-menu-layer { + pointer-events: auto; +} + +.chat-header-login-btn { + padding: 4px 7px; + min-height: 34px; + border: 0; + border-radius: 10px; + background: transparent; + cursor: pointer; +} + +/* Поле сообщения занимает почти всю ширину; действия прижаты к правому краю. */ +body.chat-topbar-overlay .composer-slot { + padding-left: 0; + padding-right: 4px; +} + +.dm-chat-input { + gap: 4px; + padding-left: 0; + padding-right: 0; +} + +.dm-actions-col { + grid-template-columns: repeat(2, 43px); + grid-template-rows: 46px; + gap: 2px; +} + +.dm-chat-input--multiline .dm-actions-col { + grid-template-columns: 43px; +} + +.dm-emoji-btn, +.dm-send-icon-btn { + min-width: 43px; + width: 43px; +} + +/* Loader не имеет права участвовать в растяжении пустого flex-контейнера. */ +.dm-history-loader { + flex: 0 0 34px; + align-self: center; + width: max-content; + max-width: calc(100% - 24px); + height: 34px; + min-height: 34px; + max-height: 34px; + top: calc(var(--topbar-height, 64px) + 8px); + margin: 0 auto -4px; + overflow: visible; +} + +.dm-history-loader__pill { + flex: 0 0 auto; + width: max-content; + max-width: 100%; + height: 34px; +} + +/* «Личные»: широкие, низкие и более плотные плитки. */ +.dm-list-screen .dm-list { + width: calc(100% + 16px); + margin-inline: -8px; + gap: 4px; +} + +.dm-list-screen .dm-dialog-card { + grid-template-columns: 50px minmax(0, 1fr) auto; + min-height: 54px; + margin: 0; + padding: 5px 6px 5px 4px; + gap: 8px; + border-radius: 15px; +} + +.dm-list-screen .dm-av, +.dm-list-screen .dm-av .avatar, +.dm-list-screen .list-item .avatar { + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; +} + +.dm-list-screen .dm-row-main, +.dm-list-screen .dm-row-meta-col { + min-height: 40px; +} + +@media (max-width: 380px) { + .dm-list-screen .dm-dialog-card { + grid-template-columns: 50px minmax(0, 1fr) auto; + } +} diff --git a/shine-UI/styles/main.css b/shine-UI/styles/main.css index 024dbf06..1c7a7b9c 100644 --- a/shine-UI/styles/main.css +++ b/shine-UI/styles/main.css @@ -11,6 +11,11 @@ --accent-soft: rgba(217, 181, 111, 0.18); --danger: #ff718f; --ok: #84f4a1; + /* Единый цвет основных действий: стартовые/регистрационные кнопки и scroll-down. */ + --shine-action-blue: #278dff; + --shine-action-blue-hover: #43a2ff; + --shine-action-blue-pressed: #1973db; + --shine-action-blue-rgb: 39, 141, 255; --radius-lg: 18px; --radius-md: 12px; --radius-sm: 9px; From 8a0275d9628daea4ed6be2bb265b5c38810d7f6b1c2adda0ac737786a8cb4b4f Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 28 Aug 2026 14:51:47 +0400 Subject: [PATCH 09/15] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B9=20sync=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB=D0=B8=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что сделано: вычистили неиспользуемый user-settings sync/DM sync хвост, сохранили сборку, обновили bundle.sh так, чтобы gradle-wrapper.jar всегда попадал в архив. Проверено: compileJava и deploy на t2 (server + UI). Не проверяли: полные интеграционные сценарии, ручные UI-флоу и продовый деплой. --- SHiNE-server/AGENTS.md | 6 +- .../java/shine/db/DatabaseInitializer.java | 6 + .../java/shine/db/dao/UserSettingsDAO.java | 133 +-- .../shine/db/entities/UserSettingEntry.java | 38 +- .../main/resources/postgres/migration_v16.sql | 112 +++ .../src/main/resources/postgres/schema_v1.sql | 59 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 18 - .../auth/SolanaUserPdaImportService.java | 2 +- .../Net_GetUserSetting_Handler.java | 1 - .../Net_ListUserSettings_Handler.java | 1 - .../Net_UpsertUserSetting_Handler.java | 81 +- .../entyties/Net_GetUserSetting_Response.java | 3 - .../Net_ListUserSettings_Response.java | 3 - .../Net_UpsertUserSetting_Request.java | 3 - .../Net_UpsertUserSetting_Response.java | 3 - .../JSON/messages/DmSyncApplySupport.java | 108 --- .../Net_DeleteConversation_Handler.java | 12 - .../messages/Net_DeleteMessage_Handler.java | 12 - ...t_MarkAllUserSettingsUnsynced_Handler.java | 49 -- .../Net_ReceiveIncomingMessage_Handler.java | 12 - .../messages/Net_SendMessagePair_Handler.java | 41 +- .../Net_UserSettingsSyncBatch_Handler.java | 129 --- .../UserSettingsSyncApplySupport.java | 61 -- ...t_MarkAllUserSettingsUnsynced_Request.java | 10 - ..._MarkAllUserSettingsUnsynced_Response.java | 13 - .../entyties/Net_SendMessagePair_Request.java | 3 - .../Net_UserSettingsSyncBatch_Request.java | 26 - .../Net_UserSettingsSyncBatch_Response.java | 62 -- .../server/sync/DmDeliveryCoordinator.java | 226 +---- .../java/server/sync/DmFederationService.java | 101 +-- .../server/sync/RemoteDmDeliveryClient.java | 74 ++ .../postgres/PostgresStorageRepository.java | 4 +- .../sync/PeriodicDmDeliveryService.java | 73 ++ .../sync/PeriodicUserSettingsSyncService.java | 256 ------ .../src/main/java/server/ws/WsServer.java | 6 +- .../src/main/resources/application.properties | 7 +- bundle.sh | 8 + docs/API/09_Operations_Index.md | 11 +- docs/API/12_Direct_Messages_Push_Calls_API.md | 117 +-- docs/API/13_User_Settings_API.md | 119 +-- docs/API/16_Server_Connection_Pool_API.md | 7 +- docs/Blockchain/README.md | 2 +- docs/Blockchain/sync-between-servers.md | 37 +- docs/Personal_Messages/README.md | 3 +- .../Доставка_и_синхронизация_DM.md | 172 ++-- docs/Personal_Messages/Протокол_DM_v1.md | 773 ++++-------------- docs/Personal_Messages/Формат_DM_v1.md | 8 +- .../Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md | 6 +- docs/Solana/user_pda/README.md | 9 +- .../details/shine_users.md | 3 +- shine-UI/js/pages/access-servers-view.js | 119 +-- shine-UI/js/pages/settings-view.js | 2 +- shine-UI/js/services/auth-service.js | 12 +- shine-UI/js/services/shine-server-resolver.js | 2 +- .../js/services/shine-user-pda-service.js | 2 +- .../formats/shine-user-pda-format-v.1.0.md | 9 +- 56 files changed, 747 insertions(+), 2428 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v16.sql delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmDeliveryClient.java create mode 100644 SHiNE-server/src/main/java/server/sync/PeriodicDmDeliveryService.java delete mode 100644 SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java 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 5bcdc551..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 @@ -32,6 +32,7 @@ public final class DatabaseInitializer { 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"; @@ -47,6 +48,7 @@ public final class DatabaseInitializer { 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() {} @@ -166,6 +168,10 @@ public final class DatabaseInitializer { 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/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/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_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 b85cf244..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, 13, 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 @@ -416,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, @@ -824,22 +804,6 @@ 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) -); - -CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner - ON dm_sync_peer_state(owner_login); - -- Изменяемое состояние доставки исходящей пары. Подписанные блоки остаются -- неизменяемыми; эта таблица описывает только сетевую доставку пары 1/2 или 3/4. CREATE TABLE IF NOT EXISTS dm_delivery_state ( @@ -868,23 +832,6 @@ CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base ON dm_delivery_state(base_key, from_login); --- У одного пользователя теперь максимум один второй access-сервер, поэтому --- достаточно одного флага ACK на событие. Курсор по времени больше не нужен. -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; - CREATE TABLE IF NOT EXISTS message_views_state ( viewer_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, 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 a7b5260a..0ef5cc0d 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 @@ -98,11 +98,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_GetDmDeliveryStatus_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; @@ -113,11 +109,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_GetDmDeliveryStatus_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; @@ -219,14 +211,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("GetDmDeliveryStatus", new Net_GetDmDeliveryStatus_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()), @@ -311,14 +298,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("GetDmDeliveryStatus", Net_GetDmDeliveryStatus_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), 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/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 c30a6b32..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(remoteLogin, 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/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 92536de9..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java +++ /dev/null @@ -1,108 +0,0 @@ -package server.logic.ws_protocol.JSON.messages; - -import shine.db.dao.SignedMessagesDAO; -import shine.db.dao.DmSyncOutboxDAO; -import shine.db.dao.DmDeliveryStateDAO; -import shine.db.entities.DmDeliveryStateEntry; -import shine.db.entities.SignedMessageEntry; -import server.sync.DmDeliveryCoordinator; - -import java.util.List; - -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 - ) {} - - public static void applySyncedItem( - String ownerLogin, String eventId, List blobsB64 - ) throws Exception { - if (blobsB64 == null || blobsB64.isEmpty() || blobsB64.size() > 2) { - throw new IllegalArgumentException("BAD_BLOB_COUNT"); - } - if (blobsB64.size() == 1) { - ApplyResult result = applySyncedBlob(ownerLogin, blobsB64.get(0)); - SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(result.messageKey()); - long createdAt = stored == null ? System.currentTimeMillis() : stored.getCreatedAtMs(); - DmSyncOutboxDAO.getInstance().upsert( - ownerLogin, result.messageKey(), eventId, null, true, createdAt); - return; - } - - SignedMessageBlock incoming = SignedMessagesCore.parseFromB64(blobsB64.get(0)); - SignedMessageBlock outgoing = SignedMessagesCore.parseFromB64(blobsB64.get(1)); - SignedMessagesCore.validatePair(incoming, outgoing); - SignedMessagesCore.verifyUsersAndSignature(incoming); - SignedMessagesCore.verifyUsersAndSignature(outgoing); - if (!outgoing.fromLogin.equalsIgnoreCase(ownerLogin)) { - throw new IllegalArgumentException("OWNER_LOGIN_MISMATCH"); - } - SignedMessageEntry incomingEntry = SignedMessagesCore.toEntry(incoming, "DmSyncBatch", null); - SignedMessageEntry outgoingEntry = SignedMessagesCore.toEntry(outgoing, "DmSyncBatch", null); - if (incoming.isContentType()) { - SignedMessagesDAO.getInstance().upsertContentPair(incomingEntry, outgoingEntry); - } else { - SignedMessagesDAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); - } - DmSyncOutboxDAO.getInstance().upsert( - ownerLogin, outgoingEntry.getMessageKey(), eventId, - incomingEntry.getMessageKey(), true, outgoingEntry.getCreatedAtMs()); - - long now = System.currentTimeMillis(); - long signedAt = Math.max(outgoing.timeMs, - Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs)); - long acceptedAt = signedAt > 0L ? Math.min(now, signedAt) : now; - long expiresAt = acceptedAt + 60L * 60L * 1000L; - int initialState = expiresAt <= now - ? DmDeliveryStateEntry.FAILED_FINAL - : DmDeliveryStateEntry.PENDING_NONE; - DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair( - outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(), - outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(), - acceptedAt, expiresAt, initialState, null, null, - initialState == DmDeliveryStateEntry.PENDING_NONE); - if (delivery != null && initialState == DmDeliveryStateEntry.PENDING_NONE) { - DmDeliveryCoordinator.assistReceivedPairAsync(eventId); - } - } -} 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 dbb7c499..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 @@ -11,7 +11,6 @@ import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; import shine.db.dao.SignedMessagesDAO; import shine.db.dao.DmDeliveryStateDAO; -import shine.db.dao.DmSyncOutboxDAO; import shine.db.entities.SignedMessageEntry; public class Net_DeleteConversation_Handler implements JsonMessageHandler { @@ -46,7 +45,6 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { DmDeliveryStateDAO.getInstance().removeMissingMessages(); - recordOutboxForLocalOwners(entry); counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { @@ -68,14 +66,4 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { return s == null || s.isBlank(); } - private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception { - String eventId = DmDeliveryIds.forEntry(entry); - if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) { - DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); - } - if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin()) - && server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) { - DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); - } - } } 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 affd543d..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 @@ -11,7 +11,6 @@ import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; import shine.db.dao.SignedMessagesDAO; import shine.db.dao.DmDeliveryStateDAO; -import shine.db.dao.DmSyncOutboxDAO; import shine.db.entities.SignedMessageEntry; public class Net_DeleteMessage_Handler implements JsonMessageHandler { @@ -46,7 +45,6 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey()); - recordOutboxForLocalOwners(entry); counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { @@ -68,14 +66,4 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { return s == null || s.isBlank(); } - private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception { - String eventId = DmDeliveryIds.forEntry(entry); - if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) { - DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); - } - if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin()) - && server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) { - DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs()); - } - } } 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 5ba82ff0..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java +++ /dev/null @@ -1,49 +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 shine.db.dao.DmSyncOutboxDAO; -import server.sync.DmSyncWakeSignal; - -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; - int dmUpdated; - if (req.getLogin() == null || req.getLogin().isBlank()) { - updated = UserSettingsDAO.getInstance().markAllUnsynced(c); - dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced(); - } else { - updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim()); - dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced(req.getLogin().trim()); - } - DmSyncWakeSignal.request(); - Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setUpdated(updated); - resp.setDmUpdated(dmUpdated); - 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 13f7e9ad..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 @@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmDeliveryCoordinator; -import shine.db.dao.DmSyncOutboxDAO; import shine.db.dao.SignedMessagesDAO; import shine.db.entities.SignedMessageEntry; @@ -70,17 +69,6 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { if (stored == null || !Arrays.equals(stored.getRawBlock(), entry.getRawBlock())) { return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения"); } - boolean receivedFromRecipientPeer = DmDeliveryCoordinator.sourceIsOtherAccessServer( - incoming.toLogin, req.getSourceServerLogin()); - DmSyncOutboxDAO.getInstance().upsert( - incoming.toLogin, - entry.getMessageKey(), - DmDeliveryIds.forEntry(entry), - null, - receivedFromRecipientPeer, - entry.getCreatedAtMs() - ); - 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 8b54d97e..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 @@ -11,7 +11,6 @@ import server.logic.ws_protocol.WireCodes; import server.sync.DmDeliveryCoordinator; import server.sync.DmFederationService; import shine.db.dao.DmDeliveryStateDAO; -import shine.db.dao.DmSyncOutboxDAO; import shine.db.dao.SignedMessagesDAO; import shine.db.entities.DmDeliveryStateEntry; import shine.db.entities.SignedMessageEntry; @@ -48,12 +47,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessageEntry incomingEntry; SignedMessageEntry outgoingEntry; - boolean fromPeer = !isBlank(req.getSourceServerLogin()); try { - String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "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 подтверждения"); } @@ -96,42 +93,22 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { String eventId = DmDeliveryIds.forEntry(outgoingEntry); long nowMs = System.currentTimeMillis(); long acceptedAtMs; - long signedAtMs = Math.max(outgoing.timeMs, - Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs)); - acceptedAtMs = fromPeer && signedAtMs > 0L ? Math.min(nowMs, signedAtMs) : nowMs; + 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, fromPeer && initialState == DmDeliveryStateEntry.PENDING_NONE + null, null, false ); - DmSyncOutboxDAO.getInstance().upsert( - outgoingEntry.getFromLogin(), outgoingEntry.getMessageKey(), eventId, - incomingEntry.getMessageKey(), fromPeer, acceptedAtMs); - - // Если этот же сервер также обслуживает получателя, его входящая копия - // имеет отдельный sync-флаг владельца-получателя. - if (DmDeliveryCoordinator.isLocalAccessServer(incomingEntry.getToLogin())) { - boolean incomingAlreadySynced = DmDeliveryCoordinator.sourceIsOtherAccessServer( - incomingEntry.getToLogin(), req.getSourceServerLogin()); - DmSyncOutboxDAO.getInstance().upsert( - incomingEntry.getToLogin(), incomingEntry.getMessageKey(), DmDeliveryIds.forEntry(incomingEntry), - null, incomingAlreadySynced, acceptedAtMs); - } - if (delivery != null && pairStatus.applied()) { - if (fromPeer) { - DmDeliveryCoordinator.assistReceivedPairAsync(delivery.getEventId()); - } else { - // Первая доставка выполняется до ответа клиенту. Два сервера - // получателя вызываются параллельно внутри координатора. - DmDeliveryCoordinator.processDueEntry(delivery); - delivery = DmDeliveryStateDAO.getInstance() - .getByOutgoingMessageKey(outgoingEntry.getMessageKey()); - } + // Первая доставка на единственный access-сервер получателя + // выполняется до ответа клиенту. + DmDeliveryCoordinator.processDueEntry(delivery); + delivery = DmDeliveryStateDAO.getInstance() + .getByOutgoingMessageKey(outgoingEntry.getMessageKey()); } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); 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_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 e843327c..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,13 +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; - private Integer dmUpdated; - - public Integer getUpdated() { return updated; } - public void setUpdated(Integer updated) { this.updated = updated; } - public Integer getDmUpdated() { return dmUpdated; } - public void setDmUpdated(Integer dmUpdated) { this.dmUpdated = dmUpdated; } -} 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_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/DmDeliveryCoordinator.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmDeliveryCoordinator.java index 4d9577f8..02d4aa88 100644 --- 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 @@ -4,7 +4,6 @@ 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.DmSyncOutboxDAO; import shine.db.dao.SignedMessagesDAO; import shine.db.dao.UserAccessServersCurrentDAO; import shine.db.entities.DmDeliveryStateEntry; @@ -14,21 +13,14 @@ import utils.config.AppConfig; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.util.ArrayList; import java.util.Base64; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.ThreadFactory; /** - * Координатор доставки исходящей DM-пары. Первая попытка выполняется до ответа - * клиенту, последующие — пятисекундным воркером. + * Координатор доставки исходящей DM-пары на единственный первый access-сервер + * получателя. Первая попытка выполняется до ответа клиенту, последующие — + * пятисекундным воркером. */ public final class DmDeliveryCoordinator { private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class); @@ -40,21 +32,10 @@ public final class DmDeliveryCoordinator { 60L * 60_000L }; private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; - private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + private static final RemoteDmDeliveryClient REMOTE = new RemoteDmDeliveryClient(); private static final DmDeliveryStateDAO DELIVERY_DAO = DmDeliveryStateDAO.getInstance(); - private static final DmSyncOutboxDAO OUTBOX_DAO = DmSyncOutboxDAO.getInstance(); private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance(); private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); - private static final ExecutorService ASSIST_EXECUTOR = new ThreadPoolExecutor( - 2, 2, 0L, TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(500), - daemonThreadFactory("dm-peer-assist"), - new ThreadPoolExecutor.DiscardPolicy()); - private static final ExecutorService RECIPIENT_EXECUTOR = new ThreadPoolExecutor( - 4, 16, 60L, TimeUnit.SECONDS, - new ArrayBlockingQueue<>(1000), - daemonThreadFactory("dm-recipient-delivery"), - new ThreadPoolExecutor.CallerRunsPolicy()); private DmDeliveryCoordinator() {} @@ -63,10 +44,6 @@ public final class DmDeliveryCoordinator { } public static void processDueEntry(DmDeliveryStateEntry snapshot) { - processDueEntry(snapshot, true); - } - - private static void processDueEntry(DmDeliveryStateEntry snapshot, boolean allowInitialHandoff) { if (snapshot == null) return; try { long now = System.currentTimeMillis(); @@ -79,27 +56,9 @@ public final class DmDeliveryCoordinator { boolean finalAttempt = snapshot.getAttemptIndex() >= ATTEMPT_OFFSETS_MS.length - 1 || now >= current.getDeliveryExpiresAtMs(); - - // Перед попытками на 5-й, 25-й и 60-й минутах сначала спрашиваем - // второй сервер отправителя: возможно, он уже доставил сообщение. - if (current.getDeliveryState() == DmDeliveryStateEntry.ACCEPTED - && (snapshot.getAttemptIndex() >= 2 || finalAttempt)) { - current = acceptPeerDeliveryStatus(current); - if (current != null && current.isDelivered()) { - DmDeliveryRealtime.notifySender(current); - return; - } - } - - DmDeliveryStateEntry afterAttempt = attemptRecipientRoutes( + DmDeliveryStateEntry afterAttempt = attemptPrimaryRecipientRoute( current, finalAttempt ? null : nextAttemptAtMs); - // После первой попытки сразу передаём полную пару второму серверу - // отправителя старой операцией ReceiveOutcomingMessage. - if (allowInitialHandoff && snapshot.getAttemptIndex() == 0 && afterAttempt != null) { - afterAttempt = handoffPairToSenderPeer(afterAttempt); - } - if (finalAttempt && afterAttempt != null && !afterAttempt.isDelivered()) { afterAttempt = DELIVERY_DAO.finishAtExpiry(afterAttempt.getEventId(), System.currentTimeMillis()); } @@ -109,28 +68,18 @@ public final class DmDeliveryCoordinator { } } - public static void assistReceivedPairAsync(String eventId) { - if (eventId == null || eventId.isBlank()) return; - ASSIST_EXECUTOR.execute(() -> { - try { - DmDeliveryStateEntry row = DELIVERY_DAO.getByEventId(eventId); - if (row != null) processDueEntry(row, false); - } catch (Exception e) { - log.warn("DM peer assist failed: eventId={}", eventId, e); - } - }); - } - - private static DmDeliveryStateEntry attemptRecipientRoutes( + private static DmDeliveryStateEntry attemptPrimaryRecipientRoute( DmDeliveryStateEntry current, Long nextAttemptAtMs ) throws Exception { if (current == null) return null; - List routes = cappedRoutes(current.getToLogin()); - String routesHash = routesHash(routes); - String alreadyDelivered = normalize(current.getDeliveredServerLogin()); - List acceptedLogins = new ArrayList<>(); - if (alreadyDelivered != null) acceptedLogins.add(alreadyDelivered); + 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) { @@ -138,138 +87,51 @@ public final class DmDeliveryCoordinator { current.getEventId(), current.getDeliveryState(), current.getDeliveredServerLogin(), routesHash, nextAttemptAtMs, "INCOMING_BLOB_NOT_FOUND", System.currentTimeMillis()); } - String incomingBlobB64 = Base64.getEncoder().encodeToString(incoming.getRawBlock()); - String ownServerLogin = ownServerLogin(); - java.util.concurrent.CompletionService completion = - new java.util.concurrent.ExecutorCompletionService<>(RECIPIENT_EXECUTOR); - int submitted = 0; - for (UserAccessServerRouteEntry route : routes) { - String routeLogin = normalize(route.getServerLogin()); - if (routeLogin == null || acceptedLogins.contains(routeLogin)) continue; - completion.submit(() -> { - try { - if (!routeLogin.equals(ownServerLogin)) { - REMOTE.receiveIncomingMessage( - routeLogin, route.getServerUrl(), incomingBlobB64, ownServerLogin); - } - return new RouteAttempt(routeLogin, null); - } catch (Exception e) { - log.info("DM recipient server unavailable: messageKey={} server={}", - current.getOutgoingMessageKey(), routeLogin); - return new RouteAttempt(routeLogin, compactError(e)); - } - }); - submitted++; - } - String lastError = null; - for (int i = 0; i < submitted && acceptedLogins.isEmpty(); i++) { - RouteAttempt result = completion.take().get(); - if (result.error() == null) { - acceptedLogins.add(result.serverLogin()); - } else { - lastError = result.error(); - } - } - - int nextState = acceptedLogins.isEmpty() - ? DmDeliveryStateEntry.ACCEPTED - : DmDeliveryStateEntry.DELIVERED; - String oneLogin = acceptedLogins.isEmpty() ? null : acceptedLogins.get(0); - return DELIVERY_DAO.updateAfterAttempt( - current.getEventId(), nextState, oneLogin, routesHash, - nextAttemptAtMs, lastError, System.currentTimeMillis()); - } - - private static DmDeliveryStateEntry handoffPairToSenderPeer(DmDeliveryStateEntry current) throws Exception { - UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin()); - if (peer == null) return current; - SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey()); - SignedMessageEntry outgoing = MESSAGES_DAO.getByMessageKey(current.getOutgoingMessageKey()); - if (incoming == null || outgoing == null) return current; + String routeLogin = normalize(route.getServerLogin()); try { - REMOTE.sendMessagePair( - peer.getServerLogin(), - peer.getServerUrl(), - Base64.getEncoder().encodeToString(incoming.getRawBlock()), - Base64.getEncoder().encodeToString(outgoing.getRawBlock()), - ownServerLogin()); - OUTBOX_DAO.markSynced(current.getFromLogin(), current.getEventId()); - } catch (Exception e) { - log.info("DM sender peer unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin()); - } - return current; - } - - private static DmDeliveryStateEntry acceptPeerDeliveryStatus(DmDeliveryStateEntry current) throws Exception { - UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin()); - if (peer == null) return current; - try { - RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus( - peer.getServerLogin(), peer.getServerUrl(), current.getOutgoingMessageKey()); - if (remote.known() && remote.delivered()) { - return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis()); + if (!routeLogin.equals(ownServerLogin())) { + REMOTE.receiveIncomingMessage( + routeLogin, + route.getServerUrl(), + Base64.getEncoder().encodeToString(incoming.getRawBlock()), + ownServerLogin()); } - return current; + return DELIVERY_DAO.updateAfterAttempt( + current.getEventId(), DmDeliveryStateEntry.DELIVERED, routeLogin, + routesHash, nextAttemptAtMs, null, System.currentTimeMillis()); } catch (Exception e) { - log.info("DM delivery status unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin()); - return current; + 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()); } } - private static UserAccessServerRouteEntry senderPeer(String senderLogin) throws Exception { - String own = ownServerLogin(); - for (UserAccessServerRouteEntry route : cappedRoutes(senderLogin)) { - String routeLogin = normalize(route.getServerLogin()); - if (routeLogin != null && !routeLogin.equals(own)) return route; - } - return null; - } - public static boolean isLocalAccessServer(String ownerLogin) throws Exception { + UserAccessServerRouteEntry route = primaryRoute(ownerLogin); String own = ownServerLogin(); - if (own == null) return false; - for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) { - if (own.equals(normalize(route.getServerLogin()))) return true; - } - return false; - } - - public static boolean sourceIsOtherAccessServer(String ownerLogin, String sourceServerLogin) throws Exception { - String source = normalize(sourceServerLogin); - String own = ownServerLogin(); - if (source == null || source.equals(own)) return false; - for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) { - if (source.equals(normalize(route.getServerLogin()))) return true; - } - return false; + return route != null && own != null && own.equals(normalize(route.getServerLogin())); } public static String currentServerLogin() { return ownServerLogin(); } - private static List cappedRoutes(String ownerLogin) throws Exception { - Map unique = new LinkedHashMap<>(); + 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; - String login = normalize(route.getServerLogin()); - if (login == null) continue; - unique.putIfAbsent(login, route); - if (unique.size() == 2) break; + if (normalize(route.getServerLogin()) != null) return route; } - return new ArrayList<>(unique.values()); + return null; } - private static String routesHash(List routes) throws Exception { - List logins = new ArrayList<>(); - for (UserAccessServerRouteEntry route : routes) { - String login = normalize(route.getServerLogin()); - if (login != null) logins.add(login); - } - logins.sort(String::compareTo); + 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(String.join("\n", logins).getBytes(StandardCharsets.UTF_8)); + 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(); @@ -280,8 +142,6 @@ public final class DmDeliveryCoordinator { return createdAtMs + ATTEMPT_OFFSETS_MS[nextAttemptIndex]; } - private record RouteAttempt(String serverLogin, String error) {} - private static String ownServerLogin() { return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); } @@ -296,16 +156,4 @@ public final class DmDeliveryCoordinator { String text = String.valueOf(e == null ? "unknown" : e.getMessage()); return text.length() <= 500 ? text : text.substring(0, 500); } - - private static ThreadFactory daemonThreadFactory(String prefix) { - return new ThreadFactory() { - private int sequence; - @Override - public synchronized Thread newThread(Runnable r) { - Thread thread = new Thread(r, prefix + "-" + (++sequence)); - thread.setDaemon(true); - return thread; - } - }; - } } 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 845a8778..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,63 +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.getServerLogin(), 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.getServerLogin(), 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.getServerLogin(), 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); } @@ -73,12 +27,17 @@ 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; @@ -89,33 +48,29 @@ public final class DmFederationService { } } } 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() { @@ -124,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/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-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/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/PeriodicUserSettingsSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java deleted file mode 100644 index b1fc6ad5..00000000 --- a/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java +++ /dev/null @@ -1,256 +0,0 @@ -package server.sync; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport; -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.ArrayList; -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 RemoteDmSyncClient DM_REMOTE = new RemoteDmSyncClient(); - 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 - ); - EXECUTOR.scheduleWithFixedDelay( - PeriodicUserSettingsSyncService::runRequestedCycleSafe, - 5L, 5L, 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 runRequestedCycleSafe() { - if (DmSyncWakeSignal.consume()) runCycleSafe(); - } - - 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; - int appliedDmItems = 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(); - appliedDmItems += stats.appliedDm(); - 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 access-data sync finished: owners={} peers={} settingsApplied={} settingsPushed={} dmApplied={}", - owners.size(), syncedPeers, appliedItems, pushedItems, appliedDmItems); - } - - 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; - - int appliedDm; - try (RemoteSyncSession session = new RemoteSyncSession(route.getServerLogin(), route.getServerUrl())) { - for (int page = 0; page < maxPages; page++) { - RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch( - session, - 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(session, entry, true); - SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey()); - pushed++; - } - } - - int dmLimit = (int) configLong("dm.sync.batchLimit", 200L, 1L, 500L); - int dmMaxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); - int dmMaxPages = (int) configLong("dm.sync.maxPagesPerPeer", 20L, 1L, 500L); - appliedDm = syncDmInSameSession(session, ownerLogin, dmLimit, dmMaxBytes, dmMaxPages); - } - - 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, appliedDm); - } - - private static int syncDmInSameSession( - RemoteSyncSession session, String ownerLogin, int limit, int maxBytes, int maxPages - ) throws Exception { - long cursorMs = 0L; - String cursorKey = ""; - List acknowledgements = new ArrayList<>(); - int applied = 0; - for (int page = 0; page < maxPages; page++) { - RemoteDmSyncClient.RemoteDmBatch batch = DM_REMOTE.dmSyncBatch( - session, ownerLogin, cursorMs, cursorKey, Math.min(limit, 500), maxBytes, acknowledgements); - acknowledgements = new ArrayList<>(); - for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) { - if (item == null || item.syncId() == null || item.syncId().isBlank()) continue; - DmSyncApplySupport.applySyncedItem(ownerLogin, item.syncId(), item.blobsB64()); - acknowledgements.add(item.syncId()); - applied++; - } - cursorMs = batch.nextStoredAtMs(); - cursorKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey(); - if (!batch.hasMore() || batch.items().isEmpty()) { - if (!acknowledgements.isEmpty()) { - DM_REMOTE.dmSyncBatch(session, ownerLogin, 0L, "", - Math.min(limit, 500), maxBytes, acknowledgements); - } - break; - } - } - return applied; - } - - 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, int appliedDm) {} -} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 03f095ac..b6186c8f 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -8,8 +8,7 @@ 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; @@ -109,8 +108,7 @@ public final class WsServer { Runtime.getRuntime().addShutdownHook(new Thread( () -> ServerConnectionPool.getInstance().close(), "server-connection-pool-shutdown")); - PeriodicDmSyncService.startOrLog(); - PeriodicUserSettingsSyncService.startOrLog(); + PeriodicDmDeliveryService.startOrLog(); server.join(); } } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 990103f7..6a0b2c8b 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -34,13 +34,10 @@ sync.importUserProfileFromPartner.enabled=false # ------------------------------------------------------------ server.version=${projectVersion} -# Доставка и межсерверная синхронизация личных сообщений. -dm.sync.enabled=true +# Доставка личных сообщений на единственный access-сервер получателя. +dm.delivery.enabled=true dm.worker.pollSeconds=5 dm.worker.dueLimit=100 -dm.sync.batchLimit=200 -dm.sync.batchMaxBytes=3000000 -dm.sync.maxPagesPerPeer=20 server.pool.pingIdleSeconds=120 server.pool.pongTimeoutSeconds=15 server.pool.requestTimeoutSeconds=12 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/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index db86f293..acad5a65 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -63,14 +63,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` | старая полная DM-пара для второго access-сервера отправителя | -| `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` | pull событий `synced=false` с ACK предыдущей страницы | -| `GetDmDeliveryStatus` | `12_Direct_Messages_Push_Calls_API.md` | read-only статус доставки по существующему `messageKey` | -| `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 приглашения к звонку | @@ -78,7 +73,9 @@ ## Важные замечания -- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload. +- UI отправляет полную подписанную пару только через `SendMessagePair`. +- `ReceiveIncomingMessage` вызывается сервером отправителя для доставки одной + входящей копии на единственный access-сервер получателя. - Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее. - `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией. - Отдельных HTTP endpoints для DM-файлов сейчас нет. diff --git a/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index ef6a9476..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` ### Назначение @@ -114,7 +114,10 @@ } ``` -Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту сервер параллельно пробует оба актуальных сервера получателя. Поэтому `deliveryState` уже может быть `delivered`; если никто не ответил, возвращается `accepted`. +Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту +сервер пробует единственный актуальный сервер получателя. Поэтому +`deliveryState` уже может быть `delivered`; если сервер получателя не +ответил, возвращается `accepted`. Возможные `deliveryState`: `accepted`, `delivered`, `failed`. @@ -132,7 +135,8 @@ ### Назначение -Используется там, где нужно принять только incoming-вариант сообщения. +Используется сервером отправителя для доставки incoming-варианта сообщения на +единственный access-сервер получателя. Принимаемые типы: @@ -152,7 +156,9 @@ } ``` -`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. Пустой `sourceServerLogin` трактуется как клиентский вызов, непустой - как peer-вызов. +`sourceServerLogin` пока передаётся для диагностики маршрута. Межсерверная +авторизация остаётся отдельным будущим этапом, но пользовательская подпись +signed-блока проверяется всегда. Официальный UI эту операцию не вызывает. Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно. @@ -262,31 +268,11 @@ Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`. -## 8. Межсерверные операции DM +## 8. Внутренняя межсерверная доставка DM -До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи. +### 8.1. `ReceiveIncomingMessage` -### 8.1. `ReceiveOutcomingMessage` - -Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит. - -```json -{ - "op": "ReceiveOutcomingMessage", - "requestId": "dm-peer-001", - "payload": { - "incomingBlobB64": "BASE64_INCOMING", - "outgoingBlobB64": "BASE64_OUTGOING", - "sourceServerLogin": "server-a" - } -} -``` - -Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки. - -### 8.2. `ReceiveIncomingMessage` - -Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя. +Передаёт одну входящую копию на единственный access-сервер получателя. ```json { @@ -301,72 +287,9 @@ Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись). -### 8.3. `DmSyncBatch` - -Pull-синхронизация событий владельца с `synced=false`. `ackSyncIds` подтверждает версии событий, успешно сохранённые из предыдущего ответа. - -```json -{ - "op": "DmSyncBatch", - "requestId": "dm-sync-001", - "payload": { - "ownerLogin": "alice", - "afterStoredAtMs": 0, - "afterMessageKey": "", - "limit": 500, - "maxBytes": 3000000, - "ackSyncIds": [] - } -} -``` - -Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла: - -```json -{ - "nextStoredAtMs": 1774700001123, - "nextMessageKey": "alice|bob|1774700000123|123456789|2", - "hasMore": false, - "items": [ - { - "syncId": "alice|bob|1774700000123|123456789|2:0:0", - "primaryMessageKey": "alice|bob|1774700000123|123456789|2", - "storedAtMs": 1774700001123, - "blobsB64": ["BASE64_INCOMING", "BASE64_OUTGOING"] - } - ] -} -``` - -Для пары порядок всегда incoming/outgoing; для входящей копии и tombstone массив содержит один blob. `syncId` — технический идентификатор конкретной ревизии только для ACK синхронизации, а не второй ID сообщения. После сохранения вызывающий сервер передаёт `syncId` в `ackSyncIds` следующего запроса. Только тогда источник ставит `synced=true`. Новый цикл начинается с `afterStoredAtMs=0`; полный сброс флагов поэтому повторно отдаёт всю историю. - -### 8.4. `GetDmDeliveryStatus` - -Единственная новая межсерверная операция доставки. Read-only проверка существующего `messageKey`; не изменяет состояние отвечающего сервера. - -```json -{ - "op": "GetDmDeliveryStatus", - "requestId": "dm-status-001", - "payload": { - "messageKey": "alice|bob|1774700000123|123456789|2" - } -} -``` - -Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя. - -Периодический процесс использует один логический последовательный сеанс поверх -постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает -`DmSyncBatch` до завершения страниц и ACK. Существующий -`MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает -`dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет. - -Основные ошибки межсерверных операций: - -- `400 / EMPTY_OWNER_LOGIN`, `EMPTY_MESSAGE_KEY`; -- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER`; -- `500 / LOCAL_SERVER_NOT_CONFIGURED`. +Операции `ReceiveOutcomingMessage`, `DmSyncBatch` и +`GetDmDeliveryStatus` удалены вместе с репликацией между access-серверами +одного пользователя. ## 9. `AckSessionDelivery` diff --git a/docs/API/13_User_Settings_API.md b/docs/API/13_User_Settings_API.md index 15135dac..e496eead 100644 --- a/docs/API/13_User_Settings_API.md +++ b/docs/API/13_User_Settings_API.md @@ -1,104 +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`; -- одновременно сбрасывает DM-outbox того же пользователя (или всех пользователей), чтобы операция замены/добавления access-сервера не оставила историю DM несинхронизированной; -- ответ дополнительно содержит `dmUpdated` — количество сброшенных DM-событий; -- нужен после добавления нового sync-сервера или при потере локальной БД. - -## 5. Синхронизация - -Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс -использует один логический последовательный сеанс поверх постоянного WSS-пула: -сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Завершение -логического сеанса не закрывает физический сокет. - -- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер; -- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`; -- периодический sync раз в 6 часов проверяет несинхронизированные настройки, догружает их по курсору и затем забирает несинхронизированные DM; -- если появляется новый 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 index a354f1f6..333ca2ab 100644 --- a/docs/API/16_Server_Connection_Pool_API.md +++ b/docs/API/16_Server_Connection_Pool_API.md @@ -55,7 +55,8 @@ - физическое соединение создаётся одно на `serverLogin`; - логические операции DM, settings и blockchain используют один WSS; - завершение `RemoteSyncSession` не закрывает физический сокет; -- известные peer берутся из `sync_servers` и `user_access_servers_current`; +- известные peer берутся из `sync_servers` и первых действующих маршрутов + `user_access_servers_current`; - список перечитывается каждые 30 секунд; - при изменении URL соединение пересоздаётся; - новые запросы не повторяются транспортом автоматически: действующие @@ -65,8 +66,8 @@ | Приоритет | Операции | | --- | --- | -| `REALTIME` | доставка DM, deletes, `GetDmDeliveryStatus` | -| `NORMAL` | настройки, `DmSyncBatch` и access-data sync | +| `REALTIME` | доставка DM и signed deletes | +| `NORMAL` | прочие межсерверные запросы | | `BULK` | blockchain heads, blocks, `AddBlock` backfill | На одном peer одновременно исполняется один запрос. Приоритет применяется к diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 516e05c4..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. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index d951a25a..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. @@ -35,7 +35,7 @@ - Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу. - Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям. - Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. -- Репликация между двумя access-серверами пользователя работает через `dm_sync_outbox.synced`, без постоянного time-cursor. +- Между access-серверами одного пользователя DM не реплицируются. - Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. ### 3.2 Блоки пользовательского блокчейна @@ -47,10 +47,8 @@ ### 3.3 Пользовательские настройки -- Отдельная таблица `user_settings`. -- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов. -- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`. -- Синхронизация идёт с учётом `time_ms` и флага `synced`. +Пользовательские настройки хранятся локально на единственном access-сервере и +между серверами не синхронизируются. ## 4. Текущая реализованная схема @@ -196,7 +194,7 @@ Full resync запускается только тогда, когда: - На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена. - При разрыве выполняется переподключение с jitter/backoff до 60 секунд. - После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд. -- Один физический канал переиспользуют DM, настройки и blockchain. +- Один физический канал переиспользуют доставка DM и blockchain. ### 5.2 Доставка новых данных (push) @@ -208,7 +206,7 @@ Full resync запускается только тогда, когда: - При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов. - Сервер с более полной историей досылает недостающее партнёру. -- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`. +- DM-history backfill между access-серверами отсутствует. ### 5.4 Разрешение конфликтов @@ -222,11 +220,11 @@ Full resync запускается только тогда, когда: 1. Клиент A отправляет пару блоков на свой сервер X. 2. Сервер X валидирует и локально сохраняет пару. -3. До ответа клиенту X параллельно отправляет входящую копию максимум двум актуальным `access_servers` B. -4. ACK хотя бы одного маршрута B означает терминальный `delivered`; второй сервер B догоняется собственной DM-синхронизацией. -5. После первой попытки X передаёт полную пару второму access-серверу A старым `ReceiveOutcomingMessage`, без delivery-state. -6. При нулевой доставке выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. Перед тремя последними X спрашивает peer A через `GetDmDeliveryStatus(messageKey)`. -7. После неудачной попытки через час ставится терминальный `failed`. +3. До ответа клиенту X отправляет входящую копию на единственный + `access_servers[0]` пользователя B через `ReceiveIncomingMessage`. +4. Успешное сохранение на сервере B означает терминальный `delivered`. +5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. +6. После неудачной попытки через час ставится терминальный `failed`. Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке. @@ -251,15 +249,14 @@ Full resync запускается только тогда, когда: | Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | | Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | | Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` | -| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано | +| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано | | Retry DM до 1 часа + UI-state | ✅ Реализовано | -| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано | -| Read-only `GetDmDeliveryStatus` | ✅ Реализовано | +| Репликация 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 | Нужна реализация | Текущая версия сервера использует постоянный WSS-пул для существующих diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index a28b5cd4..7c92140b 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -6,7 +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` — состояния доставки, retry-воркер, репликация между двумя access-серверами и UI-статусы +- `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 index accb9e6e..c14ddda5 100644 --- a/docs/Personal_Messages/Доставка_и_синхронизация_DM.md +++ b/docs/Personal_Messages/Доставка_и_синхронизация_DM.md @@ -1,138 +1,84 @@ -# Доставка и синхронизация личных сообщений +# Доставка личных сообщений -## 1. Главный принцип +## 1. Инвариант -DM считается доставленным, когда signed-входящую копию сохранил хотя бы один актуальный access-сервер получателя. Доставка на оба сервера не требуется: сервер получателя самостоятельно синхронизирует входящее сообщение со своим вторым сервером. +У каждого пользователя действует ровно один сервер доступа: первый элемент +access_servers[0] в его Solana PDA. -Клиентский `status=200` от `SendMessagePair` означает, что собственный сервер сохранил пару. Это отдельный факт от доставки получателю. +Остальные элементы старой PDA, если они существуют, не участвуют в +маршрутизации и не являются fallback. UI позволяет только заменить текущий +сервер другим. -Формат подписанного контейнера `SHiNE_DM` не меняется. Состояние доставки и флаг синхронизации — изменяемые серверные метаданные. +Межсерверной репликации личной переписки между серверами одного пользователя +нет. При смене сервера история автоматически не переносится. -## 2. Идентификаторы +## 2. Подписанные копии -- `baseKey` связывает входящую и исходящую копии одного логического сообщения; -- `incomingKey` идентифицирует копию получателя; -- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки; -- дополнительный публичный `eventId` для доставки не создаётся; -- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`. +Формат SHiNE_DM не меняется. Для сообщения клиент создаёт: -## 3. Состояния доставки +- входящую копию для получателя; +- исходящую копию для отправителя. -| Состояние | Смысл | Повторные попытки | -|---|---|---| -| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да | -| `delivered` | хотя бы один сервер получателя подтвердил запись | нет | -| `failed` | последняя попытка через час завершилась без доставки | никогда | +Сервер отправителя принимает пару через SendMessagePair, атомарно сохраняет её +и создаёт изменяемое состояние доставки. -Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя. +## 3. Основной маршрут -## 4. Обычная отправка +1. UI отправителя вызывает SendMessagePair на своём access-сервере. +2. Сервер сохраняет исходящую копию отправителя и входящую копию, необходимую + для доставки и повторных попыток. +3. Сервер читает единственный маршрут получателя из + user_access_servers_current. +4. Если это другой физический сервер, он вызывает внутреннюю операцию + ReceiveIncomingMessage и передаёт только входящую копию. +5. Сервер получателя проверяет пользовательскую подпись, идемпотентно сохраняет + входящую копию и уведомляет активные сессии. +6. Успешное сохранение означает delivered. -1. Клиент отправляет `SendMessagePair` на один свой access-сервер. -2. Сервер проверяет формат, пользователей, подписи и согласованность пары. -3. Сервер атомарно сохраняет входящую и исходящую копии. -4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя. -5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно. -6. Если хотя бы один вызов успешен, устанавливается `delivered`. -7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`. -8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`. +ReceiveIncomingMessage не является синхронизацией двух серверов одного +пользователя. Это основная доставка между серверами разных пользователей. -Успешный повтор уже сохранённого signed-блока считается ACK. Все операции должны быть идемпотентными. +## 4. Повторные попытки -## 5. Расписание повторов +Попытки привязаны ко времени первоначального принятия: -Воркер запускается каждые 5 секунд и выбирает только due-строки по индексу. Он не сканирует всю таблицу сообщений. +- сразу; +- через 30 секунд; +- через 5 минут; +- через 25 минут; +- через 1 час. -Попытки привязаны к времени первоначального принятия: +Перед каждой попыткой заново читается первый актуальный access-сервер +получателя. После финальной неудачи состояние становится failed. -| Номер | Время от старта | Сначала спросить второй сервер отправителя | -|---:|---:|---| -| 1 | сразу | нет | -| 2 | 30 секунд | нет | -| 3 | 5 минут | да | -| 4 | 25 минут | да | -| 5 | 1 час | да | +Второй сервер отправителя не опрашивается, полная пара ему не передаётся. -Из-за шага воркера повтор может начаться на несколько секунд позже указанного времени. Первая попытка выполняется немедленно и от воркера не зависит. +## 5. Состояния -На 5-й, 25-й и 60-й минутах сервер сначала вызывает у второго сервера отправителя: +- accepted — пара сохранена сервером отправителя; +- delivered — входящая копия сохранена единственным сервером получателя; +- failed — финальная попытка завершилась неудачей; +- read-receipt — отдельное подписанное DM-событие. -```json -{ - "op": "GetDmDeliveryStatus", - "payload": { - "messageKey": "alice|bob|1774700000123|123456789|2" - } -} -``` +Подписанные DM-контейнеры остаются криптографическими фактами. Сетевое +deliveryState остаётся изменяемой серверной метаданной. -Ответ: +## 6. Удаления -```json -{ - "messageKey": "alice|bob|1774700000123|123456789|2", - "known": true, - "delivered": true -} -``` +DeleteMessage и DeleteConversation доставляют подписанный tombstone только на +единственные access-серверы обеих сторон. Повторное применение идемпотентно. -Операция read-only. Если peer отвечает `delivered=true`, локальный сервер устанавливает `delivered` и не обращается к серверам получателя. Ошибка или отсутствие операции на старом peer не блокирует собственную попытку. +## 7. Удалённая репликация -Перед каждой попыткой маршруты получателя заново читаются из `user_access_servers_current`. Изменение серверов учитывается только пока сообщение находится в `accepted`. После `delivered` или `failed` состояние больше не открывается. +После перехода на один сервер доступа удалены: -Если последняя проверка и попытка через час не дали ACK, устанавливается `failed`, `next_attempt_at_ms` очищается и сообщение больше никогда автоматически не отправляется. +- ReceiveOutcomingMessage; +- DmSyncBatch; +- GetDmDeliveryStatus; +- dm_sync_outbox; +- dm_sync_peer_state; +- ACK-флаги synced; +- периодический DM-backfill между access-серверами. -## 6. Старая межсерверная доставка - -Форматы существующих операций не расширяются данными о результате доставки: - -- `ReceiveOutcomingMessage` получает прежнюю пару `incomingBlobB64` + `outgoingBlobB64` и необязательный `sourceServerLogin`; -- `ReceiveIncomingMessage` получает один `incomingBlobB64` и необязательный `sourceServerLogin`. - -Второй сервер отправителя после получения пары создаёт собственное локальное состояние доставки и самостоятельно пробует маршруты получателя. Серверы обмениваются результатом только через read-only `GetDmDeliveryStatus`. - -## 7. Догоняющая синхронизация двух серверов пользователя - -Для каждого владельца сервер ведёт outbox с флагом `synced`: - -- исходящая пара отправителя — один элемент с двумя blob в порядке incoming/outgoing; -- входящая копия получателя — один элемент с одним blob; -- read-receipt и tombstone применяются теми же проверенными обработчиками. - -`DmSyncBatch` возвращает только элементы с `synced=false`. Получатель проверяет signed-блоки, сохраняет их идемпотентно и в следующем запросе подтверждает `syncId` через `ackSyncIds`. Источник ставит `synced=true` только после ACK. - -При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля. - -`sourceServerLogin` является единственным признаком межсерверного вызова для этих операций: если поле пустое, запрос считается клиентским и его запись не должна сразу переводиться в `synced=true`. - -Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы. - -Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь. - -## 8. UI - -| Вид | Значение | -|---|---| -| одна серая галочка | собственный сервер принял сообщение (`accepted`) | -| одна светлая галочка | хотя бы один сервер получателя принял сообщение (`delivered`) | -| две светлые галочки | пришёл существующий read-receipt | -| красный `!` и «Сообщение не доставлено» | окончательное состояние `failed` | - -Read-receipt имеет приоритет над delivery-индикатором: если сообщение прочитано, оно заведомо было доставлено. - -Сервер сообщает поздние изменения через `DmDeliveryStateChanged` с полями `outgoingKey`, `baseKey`, `deliveryState`. При повторном открытии чата то же состояние приходит в `GetDirectMessages`. - -## 9. Нагрузка и отказоустойчивость - -- воркер выбирает только due-записи по частичному индексу; -- терминальные строки не попадают в рабочую выборку; -- два маршрута первой попытки выполняются параллельно; -- ограниченный пул потоков и очередь защищают сервер при всплеске отправок; -- сетевой lease и сравнение версии строки предотвращают одновременную обработку одной задачи несколькими worker-потоками; -- повторная запись одного signed-блока безопасна; -- отсутствие второго сервера отправителя не мешает собственной доставке; -- отсутствие обоих серверов получателя завершает задачу через час. - -## 10. Граница доверия - -Межсерверная авторизация пока отложена. `sourceServerLogin` временно принимается на доверии, но каждый контейнер `SHiNE_DM` всё равно проходит проверку пользовательской подписи. `GetDmDeliveryStatus` сообщает только факт локального delivery-state и не изменяет данные. +Блокчейн-синхронизация через server-PDA sync_servers остаётся отдельным +механизмом и продолжает работать. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 9b457dcb..dca0965f 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -1,696 +1,199 @@ -# Личные сообщения (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` +Клиент: -Изменяемое состояние доставки, retry-расписание и репликация между двумя access-серверами подробно описаны отдельно: +1. получает актуальные clientKey отправителя и получателя; +2. шифрует входящую копию на ключ получателя; +3. шифрует исходящую копию на ключ отправителя; +4. создаёт два связанных контейнера с общим baseKey; +5. подписывает оба контейнера clientKey отправителя; +6. вызывает SendMessagePair на своём access-сервере. -- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` +Сервер отправителя атомарно сохраняет пару. Исходящая копия доступна отправителю, +а входящая хранится для доставки и повторных попыток. -Устаревшая предыдущая версия сохранена отдельно: +## 5. Единственный сервер доступа -- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` +Пользовательская PDA по-прежнему содержит массив access_servers для +совместимости формата. Действующим считается только первый элемент массива. -## 1. Основная модель +Routing-проекция user_access_servers_current содержит не более одной строки на +пользователя и строится строго из элемента с ord=1. -Личное сообщение в SHiNE хранится как две логические копии одного сообщения: +Если первый элемент отсутствует, указывает не на server-PDA или server_address +пуст, маршрут считается отсутствующим. Второй элемент автоматически не +используется. -- `type=1` — входящее сообщение для получателя; -- `type=2` — исходящая копия сообщения для отправителя. +UI позволяет: -Обе копии имеют общий логический идентификатор: +- увидеть текущий сервер; +- заменить его другим сервером; +- записать только один логин в access_servers. -- `baseKey = fromLogin|toLogin|timeMs|nonce` +Смена сервера не переносит сообщения и настройки. Новый сервер начинает с +пустой локальной истории. -Идентификатор конкретной копии: +## 6. Доставка сообщения -- `messageKey = baseKey|messageType` +### 6.1. SendMessagePair -Поля `timeMs` и `nonce` не меняются никогда. +Официальный UI отправляет пару только через SendMessagePair. -`nonce` обязателен, потому что одного `timeMs` недостаточно для гарантированной уникальности. +Сервер: -## 2. Ключи и шифрование +1. проверяет оба контейнера и их связь; +2. сохраняет пару; +3. создаёт delivery-state; +4. до ответа UI выполняет первую попытку доставки; +5. возвращает baseKey, incomingKey, outgoingKey и deliveryState. -### 2.1. Общий принцип +ReceiveOutcomingMessage удалён. -У пользователя в PDA публикуется один публичный `clientKey` в формате `Ed25519`. +### 6.2. ReceiveIncomingMessage -Из того же ключевого материала стандартным способом выводится ключ `X25519` для E2EE-шифрования DM. +Эта операция остаётся внутренней server-to-server операцией. Она нужна, когда +отправитель и получатель используют разные физические серверы. -Отдельный публичный `dmEncKey` в текущей архитектуре не хранится. +Сервер отправителя передаёт только incomingBlobB64 на единственный сервер +получателя. Принимающий сервер: -Подробности стандартного преобразования: +- разрешает получателя через свою routing-проекцию; +- заново проверяет пользовательскую подпись; +- применяет входящую копию идемпотентно; +- обновляет диалог и realtime-сессии; +- возвращает успешный ответ только после сохранения. -- `docs/Протоколы/Преобразование_ED25519_в_X25519.md` +ReceiveIncomingMessage не реплицирует историю на второй сервер пользователя. -### 2.2. Правило шифрования копий +## 7. Повторная доставка -- `type=1` шифруется на ключ получателя; -- `type=2` шифруется на ключ отправителя. +Состояния: -Следствия: +- accepted — пара сохранена сервером отправителя; +- delivered — incoming-копия сохранена сервером получателя; +- failed — закончилась финальная попытка; +- read — подтверждается отдельным type=3/4. -- входящую копию может прочитать только получатель; -- исходящую копию может прочитать только отправитель; -- сервер не должен расшифровывать DM; -- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно. +Попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час. +Перед каждой попыткой первый маршрут получателя читается заново. -### 2.3. Что именно знает сервер о `body` +Peer отправителя не получает полную пару и не опрашивается о состоянии. +DmSyncBatch и GetDmDeliveryStatus удалены. -Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob: +## 8. Чтение и realtime -- сервер проверяет внешний контейнер `SHiNE_DM`; -- сервер проверяет подпись; -- сервер проверяет служебные поля envelope; -- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит; -- сервер не обязан разбирать внутренний crypto-контейнер `body`; -- сервер не обязан знать конкретный алгоритм шифрования `body`. +GetDirectMessages возвращает историю страницами. UI использует стабильные +messageKey/baseKey для дедупликации и отображает более новую ревизию. -Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`. +dm_dialog_state хранит: -Официальный UI SHiNE при приёме такого сообщения: +- последнюю контентную копию; +- время и ключ последнего сообщения; +- unreadCount; +- watermark прочтения. -- пытается расшифровать знакомый формат `EncryptedBody_v1_0`; -- показывает текст сообщения при успешной расшифровке; -- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`; -- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`; -- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`. +WebSocket push ускоряет отображение, но после переподключения клиент запрашивает +историю у своего единственного access-сервера. -После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `` в начале текста. Legacy-вставки `` также продолжают поддерживаться при чтении. -Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope. +## 9. Подтверждения прочтения -### 2.4. Источник истины по пользователю +Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же +маршрут SendMessagePair → ReceiveIncomingMessage. -Для DM-проверки сервер использует: +Сервер хранит наибольший известный watermark, поэтому переставленные или +повторные receipts не уменьшают состояние прочтения. -- локальную runtime-проекцию пользователей как кэш; -- Solana PDA как источник истины по `clientKey` и `access_servers`. +## 10. Редактирование -Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA: +Редактирование сохраняет логический messageKey исходной копии и увеличивает +revisionTimeMs. Сервер принимает только более новую валидную ревизию. -- в `GetUser`; -- при серверной верификации DM-подписи; -- перед межсерверной маршрутизацией через `access_servers`. +Если доставка исходного сообщения ещё не завершилась, следующая попытка +использует актуальную сохранённую incoming-копию. -## 3. Типы сообщений +## 11. Удаление -В версии v1 используются следующие `messageType`: +DeleteMessage принимает type=5/6. DeleteConversation принимает type=7/8. -- `1` — входящее сообщение; -- `2` — исходящая копия сообщения; -- `3` — входящее подтверждение прочтения; -- `4` — исходящая копия подтверждения прочтения; -- `5` — сообщение удалено отправителем; -- `6` — сообщение удалено получателем; -- `7` — переписка удалена отправителем; -- `8` — переписка удалена получателем. +Удаление является подписанным tombstone. Оно: -Типы `1/2` — это обычные контентные DM, в том числе их последующие ревизии. +- проверяется как обычный DM-контейнер; +- применяется идемпотентно; +- удаляет или блокирует соответствующий локальный контент; +- доставляется только на единственные access-серверы обеих сторон. -Типы `3/4` — служебные подтверждения прочтения. +Если сервер уже знает более новый tombstone, старая ревизия контента не +восстанавливает удалённую историю. -Типы `5/6/7/8` — служебные события удаления без зашифрованного тела. +## 12. Хранение -## 4. Базовые операции +Основные таблицы: -### 4.1. Создание нового сообщения +- signed_messages — подписанные DM-контейнеры; +- dm_dialog_state — материализованный список диалогов; +- dm_delivery_state — изменяемое состояние сетевой доставки; +- user_access_servers_current — единственный действующий маршрут пользователя. -Новое сообщение отправляется парой блоков через: +Удалены: -- `SendMessagePair` -- `ReceiveOutcomingMessage` как алиас +- dm_sync_outbox; +- dm_sync_peer_state; +- user_settings_sync_peer_state; +- synced-флаги репликации. -Пару создаёт автор сообщения. +## 13. Границы текущей версии -Пара содержит: +- межсерверная авторизация ServerHello пока не подписана; +- sourceServerLogin не является криптографическим доказательством сервера; +- пользовательская подпись каждого SHiNE_DM проверяется независимо от + транспорта; +- автоматической миграции истории при смене access-сервера нет; +- резервного fallback-сервера нет. -- одну входящую копию `type=1`; -- одну исходящую копию `type=2`; -- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`; -- одинаковый логический plaintext; -- разные ciphertext для разных владельцев копий. +## 14. Сохранённые механизмы -### 4.2. Редактирование сообщения +Переход на один access-сервер не затрагивает: -Редактирование общего текста делает автор сообщения. - -При редактировании: - -- `baseKey` остаётся тем же; -- `messageType` остаётся тем же; -- `revisionTimeMs` увеличивается; -- автор заново шифрует обе копии и снова отправляет пару через `SendMessagePair`. - -Если `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. Два сервера у отправителя и получателя - -Актуальный runtime исходит из ограничения: - -- у одного пользователя не более двух `access_servers`; -- следовательно, у локального сервера есть не более одного peer для репликации данных пользователя. - -Протокол поддерживает ситуацию, когда: - -- у отправителя один или два `access_servers`; -- у получателя один или два `access_servers`; -- часть серверов у сторон совпадает; -- часть серверов уникальна. - -Из этого следуют требования: - -- все DM-операции должны быть идемпотентны; -- повторное получение уже известного события не должно ломать состояние; -- дубль tombstone должен быть безопасен; -- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone. - -## 8. Методы и их роли - -### 8.1. Существующие методы, которые сохраняются - -- `SendMessagePair` -- `ReceiveOutcomingMessage` -- `ReceiveIncomingMessage` -- `DeleteMessage` -- `DeleteConversation` - -Их роли в v1: - -- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором; -- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt; -- `ReceiveOutcomingMessage` — алиас `SendMessagePair`. - -### 8.2. Межсерверная синхронизация - -- `ReceiveOutcomingMessage` — прежняя полная пара второго access-сервера отправителя; -- `ReceiveIncomingMessage` — прежняя одиночная входящая копия; -- `DmSyncBatch` — pull событий с `synced=false` и ACK сохранённой предыдущей страницы; -- `GetDmDeliveryStatus` — единственная новая read-only проверка доставки по существующему `messageKey`. - -Delivery-state между серверами не передаётся и не объединяется. - -### 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": "..." - } -} -``` - -Правила: - -- клиенту достаточно отправить пару на один любой доступный сервер; -- успешный `status=200` означает локальное сохранение; -- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя; -- сервер после принятия сам отвечает за дальнейшую межсерверную доставку. - -Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`. - -### 10.2. `ReceiveIncomingMessage` - -Назначение: - -- приём одной входящей копии по схеме server-to-server; -- приём входящего редактирования; -- приём входящего read-receipt. - -Request: - -```json -{ - "op": "ReceiveIncomingMessage", - "requestId": "req-456", - "payload": { - "incomingBlobB64": "...", - "sourceServerLogin": "server-a" - } -} -``` - -`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки. - -### 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-событием синхронизации, но в обычную историю страницы не подмешивается. - -Для исходящих элементов сервер также возвращает единственное поле `deliveryState`. - -## 11. Межсерверная доставка - -### 11.1. Клиентская сторона - -Клиенту достаточно отправить сообщение на: - -- любой один доступный сервер. - -### 11.2. Серверная сторона - -После локального принятия пара получает изменяемое состояние `accepted`. Сервер сразу вызывает до двух текущих маршрутов получателя параллельно. Успех хотя бы одного маршрута переводит сообщение в терминальное `delivered`; ждать второй маршрут не требуется. - -После первой попытки полная пара передаётся единственному второму access-серверу отправителя через прежний `ReceiveOutcomingMessage`. Peer самостоятельно пробует актуальные маршруты получателя; delivery-state в запрос не входит. - -Идемпотентность обязательна. ACK означает запись в БД; повтор уже известной ревизии считается успешным ACK. - -### 11.3. Догоняющая синхронизация истории - -Для восстановления пропущенных DM-событий между access-серверами используется pull-операция: - -- `DmSyncBatch` - -Второй сервер запрашивает outbox-события владельца с `synced=false`, сохраняет их и передаёт подтверждённые `syncId` в `ackSyncIds` следующего запроса. Источник ставит `synced=true` только после ACK. Каждый цикл начинается с начала списка; курсор нужен только для страниц текущего цикла. - -Полная пара отправителя передаётся двумя blob. Входящая копия получателя и tombstone передаются одним blob. - -Полная пара отправителя передаётся одним элементом с двумя blob в порядке incoming/outgoing. Входящая копия получателя и tombstone передаются одним blob. - -При применении событий, полученных через `DmSyncBatch`, сервер: - -- проверяет формат `SHiNE_DM`; -- проверяет подпись; -- применяет существующие правила ревизий, read-receipt и tombstone; -- не отправляет realtime/push-уведомления клиентам; -- не запускает повторный fan-out, чтобы не создавать циклы. - -Синхронизация настроек и DM выполняется одним периодическим процессом и через -один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие -этого логического сеанса не закрывает физическое соединение с peer. Выборка DM -использует частичный индекс по `synced=false`. - -В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков. - -### 11.4. Ошибки доставки - -Если ни один сервер получателя не подтвердил запись, попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час от первоначального принятия. - -Перед попытками через 5 минут, 25 минут и час сервер read-only спрашивает peer отправителя через `GetDmDeliveryStatus(messageKey)`. Если peer уже доставил хотя бы на один сервер, сообщение считается доставленным. - -После неудачной последней попытки устанавливается `failed`; дальнейших автоматических попыток и кнопки ручного повтора нет. - -## 12. Хранение в БД - -Основная таблица остаётся: - -- `signed_messages` - -В ней должны сохраняться: - -- обычные контентные DM; -- read-receipt DM; -- tombstone одного сообщения; -- tombstone удаления переписки. - -Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие. - -Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы: - -- защищать от повторного приёма старых версий; -- не терять факт удаления; -- корректно синхронизировать событие между серверами. - -Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. - -Изменяемая сетевая часть хранится отдельно: - -- `dm_delivery_state` — состояние и расписание доставки исходящей пары; -- `dm_sync_outbox` — событие владельца и единственный флаг ACK `synced`; -- частичные индексы содержат только due/unsynced строки. - -Legacy-таблица `dm_sync_peer_state` после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется. - -## 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-реализация массовой перешифровки; -- межсерверная авторизация DM-синхронизации и доставки. - -## 15. UI списка диалогов и загрузки истории - -Актуальные правила клиентского интерфейса: - -- превью диалога выбирается по самому новому контентному сообщению независимо от направления `in/out`; -- если локально уже есть более новое расшифрованное сообщение, чем в серверной сводке `ListContacts`, клиент использует локальный текст и время до следующего согласования с сервером; -- локальное сообщение сравнивается с `lastMessageTimeMs`, поэтому входящие и исходящие сообщения участвуют в выборе на равных; -- индикатор загрузки истории всегда является компактным элементом фиксированной высоты сверху списка и не растягивается на высоту пустого чата; -- клик по аватару или логину собеседника открывает меню перехода в профиль или связи этого пользователя. - -Эти правила относятся только к UI и не изменяют сетевой или байтовый формат DM v1. +- байтовый формат SHiNE_DM; +- шифрование входящей и исходящей копий; +- пользовательские подписи; +- deliveryState и retry-воркер; +- read-receipts; +- синхронизацию пользовательских блокчейнов через server-PDA sync_servers; +- общий постоянный WSS ServerConnectionPool. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index dcb51ef0..871ab0a0 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -340,7 +340,13 @@ ReadReceiptBody_v1_0 В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. -Межсерверные операции `ReceiveOutcomingMessage`, `ReceiveIncomingMessage` и `DmSyncBatch` не вводят новый байтовый формат DM. Они передают уже сохранённые raw-контейнеры `SHiNE_DM` в Base64. `ackSyncIds` подтверждает только факт сохранения синхронизированной ревизии; delivery-state между серверами не передаётся. Принимающий сервер заново проверяет подпись и применяет контейнер по его `messageType`. +`SendMessagePair` передаёт входящую и исходящую подписанные копии от UI на +сервер отправителя. Внутренняя операция `ReceiveIncomingMessage` передаёт +только входящую raw-копию в Base64 на единственный access-сервер получателя. +Обе операции используют существующий байтовый формат `SHiNE_DM`. +Межсерверные `ReceiveOutcomingMessage` и `DmSyncBatch` удалены. +Принимающий сервер заново проверяет пользовательскую подпись и применяет +контейнер по его `messageType`. ## 14. Превью в списке диалогов 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/js/pages/access-servers-view.js b/shine-UI/js/pages/access-servers-view.js index b7a68d91..d525e5a5 100644 --- a/shine-UI/js/pages/access-servers-view.js +++ b/shine-UI/js/pages/access-servers-view.js @@ -6,8 +6,8 @@ import { resolveShineServerByServerLogin } from '../services/shine-server-resolv import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js'; import { getTopupSiteUrl } from '../services/solana-wallet-service.js'; -export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' }; -const MAX_ACCESS_SERVERS = 2; +export const pageMeta = { id: 'access-servers-view', title: 'Сервер доступа' }; +const MAX_ACCESS_SERVERS = 1; function escapeHtml(value) { return String(value || '') @@ -281,9 +281,10 @@ export function render({ navigate }) { introCard.innerHTML = `

Где хранятся личные данные

- Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках. + Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках. Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния, - а доступ пользователя и приватная переписка хранятся только на этих серверах. + а настройки и приватная переписка хранятся только на выбранном сервере. + При смене сервера прежняя переписка и настройки автоматически не переносятся.

`; @@ -291,7 +292,7 @@ export function render({ navigate }) { listCard.className = 'card stack'; const listTitle = document.createElement('p'); listTitle.className = 'field-label'; - listTitle.textContent = 'Текущий список серверов доступа'; + listTitle.textContent = 'Текущий сервер доступа'; const listHint = document.createElement('p'); listHint.className = 'meta-muted'; listHint.textContent = sessionLogin @@ -308,10 +309,10 @@ export function render({ navigate }) { addCard.className = 'card stack'; const addTitle = document.createElement('p'); addTitle.className = 'field-label'; - addTitle.textContent = 'Добавить сервер доступа'; + addTitle.textContent = 'Сменить сервер доступа'; const addHint = document.createElement('p'); addHint.className = 'meta-muted'; - addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.'; + addHint.textContent = 'Введите логин нового сервера или несколько первых букв, затем выберите сервер из подсказок. Новый сервер заменит текущий.'; const addInput = document.createElement('input'); addInput.className = 'input'; addInput.type = 'text'; @@ -326,13 +327,12 @@ export function render({ navigate }) { const addButton = document.createElement('button'); addButton.className = 'primary-btn'; addButton.type = 'button'; - addButton.textContent = 'Добавить сервер'; + addButton.textContent = 'Сменить сервер'; addButton.disabled = true; addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton); const refreshAddButton = () => { addButton.disabled = operationBusy - || currentAccessServers.length >= MAX_ACCESS_SERVERS || (!selectedCandidate && !normalizeLogin(addInput.value)); }; @@ -352,9 +352,7 @@ export function render({ navigate }) { addInput.value = candidate.login; addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`; } else { - addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS - ? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.' - : 'Для изменения списка понадобится подпись root key.'; + addStatus.textContent = 'Для смены сервера понадобится подпись root key.'; } }; @@ -368,69 +366,15 @@ export function render({ navigate }) { return; } - currentAccessServers.forEach((server, index) => { - const button = document.createElement('button'); - button.className = 'text-btn'; - button.type = 'button'; - button.disabled = operationBusy; - button.innerHTML = ` + currentAccessServers.forEach((server) => { + const row = document.createElement('div'); + row.innerHTML = ` @${escapeHtml(server.login)} ${escapeHtml(server.url || 'URL не указан')} `; - button.addEventListener('click', () => { - if (operationBusy) return; - const isLast = currentAccessServers.length <= 1; - const performDisable = async () => { - const nextList = currentAccessServers - .map((item) => item.login) - .filter((login) => login !== server.login); - try { - await updateAccessServers(nextList, { - statusTarget: listStatus, - successText: `Сервер доступа @${server.login} отключён.`, - inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`, - }); - } catch { - // Сообщение уже показано в статусе. - } - }; - - const askFinal = (noteText = '') => { - confirmModal?.open({ - title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?', - text: isLast - ? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.' - : `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`, - note: noteText, - confirmLabel: isLast ? 'Понимаю' : 'Да', - cancelLabel: isLast ? 'Отмена' : 'Нет', - onConfirm: performDisable, - }); - }; - - if (isLast) { - confirmModal?.open({ - title: 'Отключить последний сервер?', - text: `У пользователя остался только один сервер доступа: @${server.login}.`, - note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.', - confirmLabel: 'Продолжить', - cancelLabel: 'Отмена', - onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'), - }); - return; - } - - askFinal(); - }); - listBody.append(button); - if (index < currentAccessServers.length - 1) { - const divider = document.createElement('div'); - divider.style.height = '1px'; - divider.style.background = 'rgba(255,255,255,0.08)'; - listBody.append(divider); - } + listBody.append(row); }); }; @@ -465,16 +409,13 @@ export function render({ navigate }) { renderServerList(); refreshAddButton(); listStatus.textContent = rows.length - ? `Найдено серверов доступа: ${rows.length}` + ? 'Сервер доступа загружен из PDA.' : 'В PDA пользователя пока нет серверов доступа.'; - if (rows.length >= MAX_ACCESS_SERVERS) { - addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'; - } } catch (error) { currentAccessServers = []; renderServerList(); refreshAddButton(); - listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.'; + listStatus.textContent = error?.message || 'Не удалось прочитать сервер доступа.'; } }; @@ -559,7 +500,7 @@ export function render({ navigate }) { const passwordResult = await passwordModal?.open({ title: 'Нужен пароль для обновления серверов доступа', - text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', + text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', note: savedClient ? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.' : 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.', @@ -604,7 +545,7 @@ export function render({ navigate }) { const normalizedList = uniqueLogins(nextLogins); setOperationBusy(true); - target.textContent = String(inFlightText || 'Обновляем список серверов доступа...'); + target.textContent = String(inFlightText || 'Обновляем сервер доступа...'); let signingMaterial = null; try { const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); @@ -626,7 +567,7 @@ export function render({ navigate }) { if (isInsufficientFundsForRentError(error)) { showTopupRequiredStatus(target, signingMaterial?.clientAddress); } else { - target.textContent = error?.message || 'Не удалось обновить список серверов доступа.'; + target.textContent = error?.message || 'Не удалось обновить сервер доступа.'; } throw error; } finally { @@ -662,29 +603,25 @@ export function render({ navigate }) { return; } if (currentAccessServers.some((item) => item.login === login)) { - addStatus.textContent = `Сервер @${login} уже есть в списке.`; - return; - } - if (currentAccessServers.length >= MAX_ACCESS_SERVERS) { - addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.'; + addStatus.textContent = `Сервер @${login} уже выбран.`; return; } try { const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); confirmModal?.open({ - title: 'Добавить сервер доступа?', - text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`, + title: 'Сменить сервер доступа?', + text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`, note: resolved.httpBase - ? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.` - : 'Изменение будет записано в Solana user PDA.', + ? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.` + : 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.', onConfirm: async () => { - const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]); + const nextList = [resolved.serverLogin]; try { await updateAccessServers(nextList, { statusTarget: addStatus, - successText: `Сервер доступа @${resolved.serverLogin} добавлен.`, - inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`, + successText: `Сервер доступа заменён на @${resolved.serverLogin}.`, + inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`, }); } catch { // Сообщение уже показано в статусе. @@ -698,7 +635,7 @@ export function render({ navigate }) { screen.append( renderHeader({ - title: 'Серверы доступа', + title: 'Сервер доступа', leftAction: { label: '←', onClick: () => navigate('settings-view') }, }), introCard, diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index afcd046b..015ffec0 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -44,7 +44,7 @@ export function render({ navigate }) { diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 86525d2c..d10cac30 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2611,14 +2611,8 @@ export class AuthService { async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) { const body = { incomingBlobB64, outgoingBlobB64 }; - const primaryOp = 'ReceiveOutcomingMessage'; - let response = await this.ws.request(primaryOp, body); - if (response.status === 404) { - response = await this.ws.request('SendMessagePair', body); - if (response.status !== 200) throw opError('SendMessagePair', response); - return response.payload || {}; - } - if (response.status !== 200) throw opError(primaryOp, response); + const response = await this.ws.request('SendMessagePair', body); + if (response.status !== 200) throw opError('SendMessagePair', response); return response.payload || {}; } @@ -2938,7 +2932,6 @@ export class AuthService { valueText = '', valueNum = 0, storagePwd, - syncDelivery = false, }) { const cleanLogin = String(login || '').trim(); const cleanSettingKey = String(settingKey || '').trim(); @@ -2977,7 +2970,6 @@ export class AuthService { value_num: Math.trunc(cleanValueNum), client_key: clientKey, signature, - sync_delivery: !!syncDelivery, }); if (response.status !== 200) throw opError('UpsertUserSetting', response); return response.payload || {}; diff --git a/shine-UI/js/services/shine-server-resolver.js b/shine-UI/js/services/shine-server-resolver.js index c4031780..68c9d34b 100644 --- a/shine-UI/js/services/shine-server-resolver.js +++ b/shine-UI/js/services/shine-server-resolver.js @@ -69,7 +69,7 @@ export async function resolveShineServerForUserLogin({ login, solanaEndpoint, fa solanaEndpoint, }); const accessServers = Array.isArray(parsedUser?.accessServers) - ? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean).slice(0, 2) + ? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean).slice(0, 1) : []; const pickedServerLogin = accessServers[0] || normalizeShineServerLogin(fallbackServerLogin); if (!pickedServerLogin) { diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index 8e8cb746..41e10ddf 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -30,7 +30,7 @@ const BLOCK_TYPE_TRUSTED_STATE = 70; const SESSIONS_MODE_MIXED = 1; const SESSION_TYPE_USER = 1; const SESSION_TYPE_HOMESERVER = 100; -const MAX_EFFECTIVE_ACCESS_SERVERS = 2; +const MAX_EFFECTIVE_ACCESS_SERVERS = 1; let solanaLibPromise = null; function loadSolanaLib() { diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 32440859..877a827f 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -291,8 +291,13 @@ AccessServersBlock - блок может отсутствовать, если серверы доступа не заданы; - список может обновляться при изменении маршрутизации пользователя; -- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; -- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. +- `access_servers` — массив логинов серверов доступа/relay. Формат массива + сохранён для совместимости, но текущая клиентская и серверная логика использует + только первый элемент `access_servers[0]`; +- остальные элементы игнорируются без fallback; +- официальный UI записывает ровно один сервер и позволяет только заменить его; +- Solana-программа не обязана проверять, что логин действительно зарегистрирован + как сервер. ## 14. SessionsBlock From f2fdb8195cf142bd91c0d5b603f37efb4482f521c899e26afc43a6ba2f4d54ec Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 28 Aug 2026 15:07:43 +0400 Subject: [PATCH 10/15] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=B8=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20=D1=82=D0=B5=D0=BC=D0=BD=D0=B5?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перевёл общий синий акцент на более тёмный оттенок и применил его к кнопкам стартовой страницы и экранам настроек. Проверено: deploy на t1 server/UI. Не проверяли: ручной визуальный прогон в браузере и остальные экраны. --- shine-UI/styles/components.css | 35 ++++++++++++++++++---------------- shine-UI/styles/main.css | 11 ++++++----- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 1a12ac35..d27c3523 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -743,33 +743,36 @@ } .shine-btn--primary { - border-color: rgba(255, 226, 163, 0.54); + border-color: rgba(var(--shine-action-blue-rgb), 0.72); background: - linear-gradient(180deg, rgba(255, 240, 200, 0.16), transparent 30%), - linear-gradient(180deg, rgba(155, 116, 61, 0.62), rgba(49, 35, 28, 0.7)); - color: #fff3d8; + linear-gradient(180deg, rgba(255, 255, 255, 0.10), transparent 30%), + linear-gradient(180deg, rgba(var(--shine-action-blue-rgb), 0.92), rgba(8, 20, 44, 0.92)); + color: #eef5ff; box-shadow: - inset 0 1px 0 rgba(255, 246, 216, 0.4), - inset 0 -20px 34px rgba(31, 20, 15, 0.34), - 0 15px 34px rgba(0, 0, 0, 0.42), - 0 4px 20px rgba(216, 173, 98, 0.16); + inset 0 1px 0 rgba(255, 255, 255, 0.24), + inset 0 -20px 34px rgba(0, 0, 0, 0.30), + 0 15px 34px rgba(0, 0, 0, 0.38), + 0 4px 20px rgba(var(--shine-action-blue-rgb), 0.18); } .shine-btn--primary::before { background: - radial-gradient(115% 65% at 50% 110%, rgba(244, 190, 87, 0.3), transparent 64%), + radial-gradient(115% 65% at 50% 110%, rgba(122, 170, 255, 0.32), transparent 64%), linear-gradient(108deg, rgba(255, 255, 255, 0.12), transparent 38%); } .shine-btn--register { - border-color: rgba(197, 215, 243, 0.34); + border-color: rgba(var(--shine-action-blue-rgb), 0.34); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 28%), + linear-gradient(180deg, rgba(12, 36, 82, 0.88), rgba(5, 14, 32, 0.82)); } .shine-btn--view { - color: rgba(244, 235, 221, 0.92); + color: rgba(236, 244, 255, 0.92); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 28%), - linear-gradient(180deg, rgba(10, 22, 47, 0.62), rgba(4, 10, 24, 0.76)); + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%), + linear-gradient(180deg, rgba(15, 42, 94, 0.78), rgba(5, 13, 30, 0.82)); } .shine-btn--view::after { @@ -777,10 +780,10 @@ } .shine-btn--settings { - color: rgba(244, 235, 221, 0.88); + color: rgba(236, 244, 255, 0.9); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 28%), - linear-gradient(180deg, rgba(9, 19, 40, 0.58), rgba(4, 9, 22, 0.76)); + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%), + linear-gradient(180deg, rgba(12, 34, 78, 0.78), rgba(4, 10, 24, 0.82)); } .shine-btn--settings::after { diff --git a/shine-UI/styles/main.css b/shine-UI/styles/main.css index 1c7a7b9c..2fd8cc4b 100644 --- a/shine-UI/styles/main.css +++ b/shine-UI/styles/main.css @@ -11,11 +11,12 @@ --accent-soft: rgba(217, 181, 111, 0.18); --danger: #ff718f; --ok: #84f4a1; - /* Единый цвет основных действий: стартовые/регистрационные кнопки и scroll-down. */ - --shine-action-blue: #278dff; - --shine-action-blue-hover: #43a2ff; - --shine-action-blue-pressed: #1973db; - --shine-action-blue-rgb: 39, 141, 255; + /* Единый цвет основных действий: стартовые/регистрационные кнопки, + * настройки и scroll-down. */ + --shine-action-blue: #1248a3; + --shine-action-blue-hover: #1b62cf; + --shine-action-blue-pressed: #0d3577; + --shine-action-blue-rgb: 18, 72, 163; --radius-lg: 18px; --radius-md: 12px; --radius-sm: 9px; From 357a05f7ec953f2c2b1934e35ffdd566bba1c08be4cb0e22dc04ff7bfb8fecfb Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 28 Aug 2026 16:51:14 +0400 Subject: [PATCH 11/15] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/dao/UserAccessServersCurrentDAO.java | 42 +++ .../ws_protocol/JSON/ConnectionContext.java | 35 +++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../JSON/JsonInboundProcessor.java | 16 + .../JSON/RemoteUserOperationPolicy.java | 31 ++ .../handlers/auth/AuthAccessScopeSupport.java | 81 +++++ .../auth/Net_AuthChallenge_Handler.java | 1 + .../auth/Net_CreateAuthSession__Handler.java | 6 + .../auth/Net_ResolveLoginForAuth_Handler.java | 45 +++ .../auth/Net_SessionLogin_Handler.java | 4 + .../auth/Net_StartEspPairing_Handler.java | 18 +- .../Net_CreateAuthSession_Response.java | 4 + .../Net_ResolveLoginForAuth_Request.java | 11 + .../Net_ResolveLoginForAuth_Response.java | 31 ++ .../entyties/Net_SessionLogin_Response.java | 4 + VERSION.properties | 4 +- docs/API/02_Authentication_API.md | 82 +++++ docs/API/03_Session_Management_API.md | 7 +- docs/API/09_Operations_Index.md | 1 + shine-UI/js/pages/login-other-device-view.js | 119 +++---- shine-UI/js/pages/login-password-view.js | 295 +++++++----------- shine-UI/js/pages/login-view.js | 160 +++++++++- shine-UI/js/services/auth-service.js | 12 + shine-UI/js/services/ui-error-texts.js | 8 + shine-UI/styles/components.css | 70 +++++ 25 files changed, 821 insertions(+), 270 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/RemoteUserOperationPolicy.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthAccessScopeSupport.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ResolveLoginForAuth_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ResolveLoginForAuth_Response.java 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 c53d0120..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 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 37bf1a9a..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 @@ -27,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; @@ -73,6 +78,12 @@ 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 можно было отправлять сообщения клиенту. @@ -197,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; @@ -209,6 +242,7 @@ public class ConnectionContext { sessionLoginNonceExpiresAtMs = 0; authenticationStatus = AUTH_STATUS_NONE; + userServerScope = USER_SERVER_SCOPE_NONE; wsSession = null; serverConnection = false; remoteServerLogin = null; @@ -222,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 0ef5cc0d..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; @@ -155,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()), @@ -242,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), 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/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/VERSION.properties b/VERSION.properties index 20b9c72a..638df9af 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.7.3 -server.version=1.6.1 +client.version=1.7.4 +server.version=1.6.2 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/09_Operations_Index.md b/docs/API/09_Operations_Index.md index acad5a65..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 для входа в существующую сессию | diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 13f1b1b2..ab9d0945 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -73,7 +73,7 @@ export function render({ navigate }) { screen.append( renderHeader({ title: 'Войти через другое устройство', - leftAction: { label: '←', onClick: () => navigate('login-view') }, + leftAction: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } }, }), ); @@ -82,22 +82,17 @@ export function render({ navigate }) { panel.innerHTML = '

Войти через другое устройство

'; const formCard = document.createElement('div'); - formCard.className = 'card stack'; + formCard.className = 'card stack login-device-preparation'; formCard.innerHTML = ` - - -