SHA256
Compare commits
30
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
01321f5200 | ||
|
|
3a5851939e | ||
|
|
7d929e626e | ||
|
|
d8e0c77951 | ||
|
|
015fade4c0 | ||
|
|
da14b51e80 | ||
|
|
7a5ac01d1c | ||
|
|
5e6d64e965 | ||
|
|
c425fa41aa | ||
|
|
781157299f | ||
|
|
0f30317bb4 | ||
|
|
1f1bc0a7b9 | ||
|
|
60c8a6608a | ||
|
|
86adaf8c6b | ||
|
|
731688d16e | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b | ||
|
|
585750007d | ||
|
|
6e80ac976a | ||
|
|
c70f18fcf4 | ||
|
|
d3e6aa2be2 | ||
|
|
b7a869c514 | ||
|
|
19362b950a | ||
|
|
0a4c31fb36 | ||
|
|
fef7694b48 | ||
|
|
60206e21df | ||
|
|
b9b77c66ce | ||
|
|
745a0e39d7 |
@@ -98,14 +98,9 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+3
-3
@@ -66,7 +66,7 @@ import java.util.Objects;
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|
||||
@@ -26,6 +26,12 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_7 = 7;
|
||||
public static final int SCHEMA_VERSION_8 = 8;
|
||||
public static final int SCHEMA_VERSION_9 = 9;
|
||||
public static final int SCHEMA_VERSION_10 = 10;
|
||||
public static final int SCHEMA_VERSION_11 = 11;
|
||||
public static final int SCHEMA_VERSION_12 = 12;
|
||||
public static final int SCHEMA_VERSION_13 = 13;
|
||||
public static final int SCHEMA_VERSION_14 = 14;
|
||||
public static final int SCHEMA_VERSION_15 = 15;
|
||||
public static final 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";
|
||||
@@ -35,6 +41,12 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
||||
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
|
||||
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
|
||||
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
|
||||
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
|
||||
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
|
||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
|
||||
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -53,10 +65,8 @@ public final class DatabaseInitializer {
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
@@ -130,6 +140,31 @@ public final class DatabaseInitializer {
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_9) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_9;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_10) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_10;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_11) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_11;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_12) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V12_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_12;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_13) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_13;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_14) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_14;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_15) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_15;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
|
||||
private static volatile DbController instance;
|
||||
|
||||
private final PostgresDbController delegate;
|
||||
private volatile boolean dmDialogStateBootstrapped;
|
||||
|
||||
private DbController() {
|
||||
this.delegate = PostgresDbController.getInstance();
|
||||
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
|
||||
if (instance == null) {
|
||||
synchronized (DbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new DbController();
|
||||
DbController created = new DbController();
|
||||
instance = created;
|
||||
created.bootstrapDmDialogStateIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
|
||||
private void bootstrapDmDialogStateIfNeeded() {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
synchronized (this) {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
try (Connection connection = delegate.getConnection()) {
|
||||
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||
dmDialogStateBootstrapped = true;
|
||||
} catch (SQLException e) {
|
||||
instance = null;
|
||||
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,23 +58,17 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+102
-6
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
|
||||
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||
|
||||
rebuildStatsState(c);
|
||||
|
||||
c.commit();
|
||||
|
||||
return new CleanupResult(
|
||||
@@ -185,10 +187,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
SET likes_count = GREATEST(
|
||||
0,
|
||||
likes_count - (
|
||||
SELECT COUNT(*)
|
||||
likes_count - COALESCE((
|
||||
SELECT COUNT(*)::int
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
@@ -198,7 +200,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
AND rs.to_block_number = message_stats.to_block_number
|
||||
AND rs.to_block_hash = message_stats.to_block_hash
|
||||
AND rs.to_bch_name <> ?
|
||||
)
|
||||
), 0)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
@@ -237,10 +239,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET replies_count = MAX(
|
||||
SET replies_count = GREATEST(
|
||||
0,
|
||||
replies_count - COALESCE((
|
||||
SELECT COUNT(*)
|
||||
SELECT COUNT(*)::int
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
@@ -366,6 +368,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);
|
||||
|
||||
@@ -67,6 +67,36 @@ public final class ConnectionsStateDAO {
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM connections_state cs
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
||||
WHERE LOWER(cs.login) = LOWER(?)
|
||||
AND cs.rel_type = ?
|
||||
AND (
|
||||
LOWER(cs.to_login) = LOWER(?)
|
||||
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
|
||||
)
|
||||
LIMIT 1
|
||||
""".formatted(
|
||||
CurrentUsersSql.usersSubquery("u_login"),
|
||||
CurrentUsersSql.usersSubquery("u_bch")
|
||||
);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setInt(2, relType);
|
||||
ps.setString(3, peerLoginAnyCase);
|
||||
ps.setString(4, peerLoginAnyCase);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DmDeliveryStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Хранилище изменяемого состояния межсерверной доставки DM-пары. */
|
||||
public final class DmDeliveryStateDAO {
|
||||
private static volatile DmDeliveryStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DmDeliveryStateDAO() {}
|
||||
|
||||
public static DmDeliveryStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmDeliveryStateDAO.class) {
|
||||
if (instance == null) instance = new DmDeliveryStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry upsertPair(
|
||||
String outgoingMessageKey,
|
||||
String eventId,
|
||||
String baseKey,
|
||||
String fromLogin,
|
||||
String toLogin,
|
||||
String incomingMessageKey,
|
||||
long createdAtMs,
|
||||
long expiresAtMs,
|
||||
int initialState,
|
||||
String deliveredServerLogin,
|
||||
String routesHash,
|
||||
boolean assistImmediately
|
||||
) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
int safeState = normalizeState(initialState, deliveredServerLogin);
|
||||
if (expiresAtMs <= now && safeState == DmDeliveryStateEntry.PENDING_NONE) {
|
||||
safeState = DmDeliveryStateEntry.FAILED_FINAL;
|
||||
}
|
||||
Long nextAttemptAt = safeState == DmDeliveryStateEntry.ACCEPTED
|
||||
? now
|
||||
: null;
|
||||
String safeDeliveredLogin = safeState == DmDeliveryStateEntry.DELIVERED_ONE
|
||||
? normalize(deliveredServerLogin)
|
||||
: null;
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO dm_delivery_state (
|
||||
outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL, ?)
|
||||
ON CONFLICT (outgoing_message_key) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
base_key = EXCLUDED.base_key,
|
||||
from_login = EXCLUDED.from_login,
|
||||
to_login = EXCLUDED.to_login,
|
||||
incoming_message_key = EXCLUDED.incoming_message_key,
|
||||
created_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.created_at_ms
|
||||
ELSE EXCLUDED.created_at_ms
|
||||
END,
|
||||
delivery_expires_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_expires_at_ms
|
||||
ELSE EXCLUDED.delivery_expires_at_ms
|
||||
END,
|
||||
delivery_state = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_state
|
||||
ELSE EXCLUDED.delivery_state
|
||||
END,
|
||||
delivered_server_login = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivered_server_login
|
||||
ELSE EXCLUDED.delivered_server_login
|
||||
END,
|
||||
recipient_routes_hash = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN COALESCE(dm_delivery_state.recipient_routes_hash, EXCLUDED.recipient_routes_hash)
|
||||
ELSE EXCLUDED.recipient_routes_hash
|
||||
END,
|
||||
attempt_index = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.attempt_index
|
||||
ELSE 0
|
||||
END,
|
||||
next_attempt_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.next_attempt_at_ms
|
||||
ELSE EXCLUDED.next_attempt_at_ms
|
||||
END,
|
||||
last_attempt_at_ms = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_attempt_at_ms
|
||||
ELSE NULL
|
||||
END,
|
||||
last_error = CASE
|
||||
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_error
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, outgoingMessageKey);
|
||||
ps.setString(2, eventId);
|
||||
ps.setString(3, baseKey);
|
||||
ps.setString(4, normalize(fromLogin));
|
||||
ps.setString(5, normalize(toLogin));
|
||||
ps.setString(6, incomingMessageKey);
|
||||
ps.setLong(7, createdAtMs);
|
||||
ps.setLong(8, expiresAtMs);
|
||||
ps.setInt(9, safeState);
|
||||
setNullableString(ps, 10, safeDeliveredLogin);
|
||||
setNullableString(ps, 11, normalizeBlank(routesHash));
|
||||
setNullableLong(ps, 12, nextAttemptAt);
|
||||
ps.setLong(13, now);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
DmDeliveryStateEntry result;
|
||||
if (initialState != DmDeliveryStateEntry.PENDING_NONE || !isBlank(deliveredServerLogin)) {
|
||||
result = mergeRemote(eventId, initialState, deliveredServerLogin, routesHash, now);
|
||||
} else {
|
||||
result = getByEventId(eventId);
|
||||
}
|
||||
if (result != null && result.getDeliveryExpiresAtMs() <= now
|
||||
&& result.getDeliveryState() == DmDeliveryStateEntry.PENDING_NONE) {
|
||||
return finishAtExpiry(eventId, now);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry getByEventId(String eventId) throws SQLException {
|
||||
if (isBlank(eventId)) return null;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ?")) {
|
||||
ps.setString(1, eventId.trim());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry getByOutgoingMessageKey(String outgoingMessageKey) throws SQLException {
|
||||
if (isBlank(outgoingMessageKey)) return null;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE outgoing_message_key = ?")) {
|
||||
ps.setString(1, outgoingMessageKey.trim());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, DmDeliveryStateEntry> listByOutgoingMessageKeys(List<String> keys) throws SQLException {
|
||||
Map<String, DmDeliveryStateEntry> out = new HashMap<>();
|
||||
if (keys == null || keys.isEmpty()) return out;
|
||||
String placeholders = String.join(",", java.util.Collections.nCopies(keys.size(), "?"));
|
||||
String sql = selectColumns() + " WHERE outgoing_message_key IN (" + placeholders + ")";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (int i = 0; i < keys.size(); i++) ps.setString(i + 1, keys.get(i));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
DmDeliveryStateEntry row = mapRow(rs);
|
||||
out.put(row.getOutgoingMessageKey(), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<DmDeliveryStateEntry> listDue(long nowMs, int limit) throws SQLException {
|
||||
String sql = selectColumns() + """
|
||||
WHERE delivery_state = 0
|
||||
AND next_attempt_at_ms IS NOT NULL
|
||||
AND next_attempt_at_ms <= ?
|
||||
ORDER BY next_attempt_at_ms ASC, created_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<DmDeliveryStateEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setInt(2, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean claimAttempt(DmDeliveryStateEntry row, long nowMs, Long nextAttemptAtMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_delivery_state
|
||||
SET attempt_index = attempt_index + 1,
|
||||
next_attempt_at_ms = ?,
|
||||
last_attempt_at_ms = ?,
|
||||
last_error = NULL,
|
||||
updated_at_ms = ?
|
||||
WHERE outgoing_message_key = ?
|
||||
AND event_id = ?
|
||||
AND attempt_index = ?
|
||||
AND delivery_state = 0
|
||||
AND next_attempt_at_ms IS NOT NULL
|
||||
AND next_attempt_at_ms <= ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long networkLeaseUntilMs = nowMs + 60_000L;
|
||||
Long claimedNextAttemptAtMs = nextAttemptAtMs == null
|
||||
? networkLeaseUntilMs
|
||||
: Math.max(nextAttemptAtMs, networkLeaseUntilMs);
|
||||
setNullableLong(ps, 1, claimedNextAttemptAtMs);
|
||||
ps.setLong(2, nowMs);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.setString(4, row.getOutgoingMessageKey());
|
||||
ps.setString(5, row.getEventId());
|
||||
ps.setInt(6, row.getAttemptIndex());
|
||||
ps.setLong(7, nowMs);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry mergeRemote(
|
||||
String eventId,
|
||||
int remoteState,
|
||||
String remoteDeliveredLogin,
|
||||
String remoteRoutesHash,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean previousAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
|
||||
if (current == null) {
|
||||
c.rollback();
|
||||
return null;
|
||||
}
|
||||
MergeResult merged = merge(
|
||||
current.getDeliveryState(),
|
||||
current.getDeliveredServerLogin(),
|
||||
current.getRecipientRoutesHash(),
|
||||
remoteState,
|
||||
remoteDeliveredLogin,
|
||||
remoteRoutesHash
|
||||
);
|
||||
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
|
||||
merged.routesHash(), current.getNextAttemptAtMs(), current.getLastError(), nowMs);
|
||||
c.commit();
|
||||
return getByEventId(eventId);
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw e;
|
||||
} finally {
|
||||
c.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry updateAfterAttempt(
|
||||
String eventId,
|
||||
int attemptedState,
|
||||
String deliveredServerLogin,
|
||||
String routesHash,
|
||||
Long nextAttemptAtMs,
|
||||
String lastError,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean previousAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
|
||||
if (current == null) {
|
||||
c.rollback();
|
||||
return null;
|
||||
}
|
||||
MergeResult merged = merge(
|
||||
current.getDeliveryState(), current.getDeliveredServerLogin(), current.getRecipientRoutesHash(),
|
||||
attemptedState, deliveredServerLogin, routesHash
|
||||
);
|
||||
Long effectiveNext = merged.state() == DmDeliveryStateEntry.ACCEPTED
|
||||
? nextAttemptAtMs
|
||||
: null;
|
||||
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
|
||||
merged.routesHash(), effectiveNext, lastError, nowMs);
|
||||
c.commit();
|
||||
return getByEventId(eventId);
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw e;
|
||||
} finally {
|
||||
c.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DmDeliveryStateEntry finishAtExpiry(String eventId, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = CASE WHEN delivery_state = 0 THEN 3 ELSE delivery_state END,
|
||||
delivered_server_login = CASE WHEN delivery_state = 0 THEN NULL ELSE delivered_server_login END,
|
||||
next_attempt_at_ms = NULL,
|
||||
last_error = CASE WHEN delivery_state = 0 THEN 'DELIVERY_EXPIRED' ELSE last_error END,
|
||||
updated_at_ms = ?
|
||||
WHERE event_id = ? AND delivery_state = 0
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, eventId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
return getByEventId(eventId);
|
||||
}
|
||||
|
||||
/** Read-only peer confirmed that at least one recipient server accepted the message. */
|
||||
public DmDeliveryStateEntry markDeliveredFromPeer(String eventId, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = 1,
|
||||
delivered_server_login = NULL,
|
||||
next_attempt_at_ms = NULL,
|
||||
last_error = NULL,
|
||||
updated_at_ms = ?
|
||||
WHERE event_id = ? AND delivery_state = 0
|
||||
""")) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, eventId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return getByEventId(eventId);
|
||||
}
|
||||
|
||||
public int removeByBaseKey(String baseKey) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("DELETE FROM dm_delivery_state WHERE base_key = ?")) {
|
||||
ps.setString(1, baseKey);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeConversationBefore(String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM dm_delivery_state
|
||||
WHERE created_at_ms < ?
|
||||
AND ((LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)))
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, boundaryTimeMs);
|
||||
ps.setString(2, fromLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toLogin);
|
||||
ps.setString(5, fromLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeMissingMessages() throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM dm_delivery_state d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM signed_messages m WHERE m.message_key = d.outgoing_message_key
|
||||
)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private DmDeliveryStateEntry getByEventIdForUpdate(Connection c, String eventId) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ? FOR UPDATE")) {
|
||||
ps.setString(1, eventId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateMutableState(
|
||||
Connection c,
|
||||
DmDeliveryStateEntry current,
|
||||
int state,
|
||||
String deliveredLogin,
|
||||
String routesHash,
|
||||
Long nextAttemptAtMs,
|
||||
String lastError,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = ?,
|
||||
delivered_server_login = ?,
|
||||
recipient_routes_hash = ?,
|
||||
next_attempt_at_ms = ?,
|
||||
last_error = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE outgoing_message_key = ? AND event_id = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, state);
|
||||
setNullableString(ps, 2, state == DmDeliveryStateEntry.DELIVERED_ONE ? normalize(deliveredLogin) : null);
|
||||
setNullableString(ps, 3, normalizeBlank(routesHash));
|
||||
setNullableLong(ps, 4, nextAttemptAtMs);
|
||||
setNullableString(ps, 5, normalizeBlank(lastError));
|
||||
ps.setLong(6, nowMs);
|
||||
ps.setString(7, current.getOutgoingMessageKey());
|
||||
ps.setString(8, current.getEventId());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private MergeResult merge(
|
||||
int localState,
|
||||
String localLogin,
|
||||
String localHash,
|
||||
int remoteStateRaw,
|
||||
String remoteLoginRaw,
|
||||
String remoteHashRaw
|
||||
) {
|
||||
int remoteState = normalizeState(remoteStateRaw, remoteLoginRaw);
|
||||
String remoteLogin = normalize(remoteLoginRaw);
|
||||
String localNormalizedLogin = normalize(localLogin);
|
||||
String localRoutesHash = normalizeBlank(localHash);
|
||||
String remoteRoutesHash = normalizeBlank(remoteHashRaw);
|
||||
String resultHash = remoteRoutesHash != null ? remoteRoutesHash : localRoutesHash;
|
||||
|
||||
if (localState == DmDeliveryStateEntry.DELIVERED_ONE || localState == DmDeliveryStateEntry.DELIVERED_ALL) {
|
||||
return new MergeResult(DmDeliveryStateEntry.DELIVERED, localNormalizedLogin,
|
||||
localRoutesHash != null ? localRoutesHash : remoteRoutesHash);
|
||||
}
|
||||
if (remoteState == DmDeliveryStateEntry.DELIVERED_ONE || remoteState == DmDeliveryStateEntry.DELIVERED_ALL) {
|
||||
return new MergeResult(DmDeliveryStateEntry.DELIVERED, remoteLogin, resultHash);
|
||||
}
|
||||
if (localState == DmDeliveryStateEntry.FAILED_FINAL || remoteState == DmDeliveryStateEntry.FAILED_FINAL) {
|
||||
return new MergeResult(DmDeliveryStateEntry.FAILED_FINAL, null, resultHash);
|
||||
}
|
||||
return new MergeResult(DmDeliveryStateEntry.PENDING_NONE, null, resultHash);
|
||||
}
|
||||
|
||||
private int normalizeState(int state, String deliveredLogin) {
|
||||
if (state < DmDeliveryStateEntry.PENDING_NONE || state > DmDeliveryStateEntry.FAILED_FINAL) {
|
||||
return DmDeliveryStateEntry.PENDING_NONE;
|
||||
}
|
||||
if (state == DmDeliveryStateEntry.DELIVERED_ALL) return DmDeliveryStateEntry.DELIVERED;
|
||||
return state;
|
||||
}
|
||||
|
||||
private String selectColumns() {
|
||||
return """
|
||||
SELECT outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||
FROM dm_delivery_state
|
||||
""";
|
||||
}
|
||||
|
||||
private DmDeliveryStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
DmDeliveryStateEntry e = new DmDeliveryStateEntry();
|
||||
e.setOutgoingMessageKey(rs.getString("outgoing_message_key"));
|
||||
e.setEventId(rs.getString("event_id"));
|
||||
e.setBaseKey(rs.getString("base_key"));
|
||||
e.setFromLogin(rs.getString("from_login"));
|
||||
e.setToLogin(rs.getString("to_login"));
|
||||
e.setIncomingMessageKey(rs.getString("incoming_message_key"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setDeliveryExpiresAtMs(rs.getLong("delivery_expires_at_ms"));
|
||||
e.setDeliveryState(rs.getInt("delivery_state"));
|
||||
e.setDeliveredServerLogin(rs.getString("delivered_server_login"));
|
||||
e.setRecipientRoutesHash(rs.getString("recipient_routes_hash"));
|
||||
e.setAttemptIndex(rs.getInt("attempt_index"));
|
||||
long next = rs.getLong("next_attempt_at_ms");
|
||||
e.setNextAttemptAtMs(rs.wasNull() ? null : next);
|
||||
long last = rs.getLong("last_attempt_at_ms");
|
||||
e.setLastAttemptAtMs(rs.wasNull() ? null : last);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException {
|
||||
if (isBlank(value)) ps.setNull(index, Types.VARCHAR);
|
||||
else ps.setString(index, value.trim());
|
||||
}
|
||||
|
||||
private static void setNullableLong(PreparedStatement ps, int index, Long value) throws SQLException {
|
||||
if (value == null) ps.setNull(index, Types.BIGINT);
|
||||
else ps.setLong(index, value);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim().toLowerCase();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static String normalizeBlank(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record MergeResult(int state, String deliveredLogin, String routesHash) {}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Base64;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public final class DmDialogStateDAO {
|
||||
private static volatile DmDialogStateDAO instance;
|
||||
|
||||
private DmDialogStateDAO() {}
|
||||
|
||||
public static DmDialogStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmDialogStateDAO.class) {
|
||||
if (instance == null) instance = new DmDialogStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void bootstrapIfEmpty(Connection c) throws SQLException {
|
||||
if (c == null) return;
|
||||
if (hasAnyRow(c)) return;
|
||||
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
for (PairItem pair : listConversationPairs(c)) {
|
||||
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
|
||||
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||
refreshConversation(c, cleanOwner, cleanPeer);
|
||||
refreshConversation(c, cleanPeer, cleanOwner);
|
||||
}
|
||||
|
||||
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
if (cleanOwner.isEmpty()) return List.of();
|
||||
|
||||
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||
last_message_time_ms, unread_count, last_read_receipt_time_ms,
|
||||
updated_at_ms
|
||||
FROM dm_dialog_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
""")) {
|
||||
ps.setString(1, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
DialogSummary row = new DialogSummary(
|
||||
rs.getString("owner_login"),
|
||||
rs.getString("peer_login"),
|
||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||
rs.getString("last_message_blob_b64"),
|
||||
rs.getLong("last_message_time_ms"),
|
||||
rs.getInt("unread_count"),
|
||||
rs.getLong("last_read_receipt_time_ms"),
|
||||
rs.getLong("updated_at_ms"),
|
||||
true
|
||||
);
|
||||
byPeer.put(normKey(row.peerLogin()), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String peer : contacts) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||
}
|
||||
for (String peer : closeFriends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||
}
|
||||
|
||||
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
|
||||
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
|
||||
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
|
||||
row.ownerLogin(),
|
||||
row.peerLogin(),
|
||||
relationFlag,
|
||||
row.lastMessageBlobB64(),
|
||||
row.lastMessageTimeMs(),
|
||||
row.unreadCount(),
|
||||
row.lastReadReceiptTimeMs(),
|
||||
row.updatedAtMs(),
|
||||
row.hasDialog()
|
||||
));
|
||||
}
|
||||
|
||||
List<DialogSummary> out = new ArrayList<>(byPeer.values());
|
||||
out.sort((a, b) -> {
|
||||
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
|
||||
if (cmp != 0) return cmp;
|
||||
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||
if (entry == null) return;
|
||||
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
|
||||
}
|
||||
|
||||
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
|
||||
upsert(c, summary);
|
||||
if (summary.lastReadReceiptTimeMs() > 0) {
|
||||
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
|
||||
}
|
||||
}
|
||||
|
||||
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
|
||||
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
|
||||
}
|
||||
|
||||
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
|
||||
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
|
||||
|
||||
String latestSql = """
|
||||
SELECT raw_block, time_ms
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
String unreadSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM signed_messages
|
||||
WHERE LOWER(from_login) = LOWER(?)
|
||||
AND LOWER(to_login) = LOWER(?)
|
||||
AND message_type = 1
|
||||
AND time_ms > ?
|
||||
AND (read_at_ms IS NULL OR read_at_ms <= 0)
|
||||
""";
|
||||
String contentReadSql = """
|
||||
SELECT MAX(read_at_ms)
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
AND read_at_ms IS NOT NULL
|
||||
AND read_at_ms > 0
|
||||
""";
|
||||
String receiptWatermarkSql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (3, 4)
|
||||
""";
|
||||
|
||||
String lastMessageBlobB64 = "";
|
||||
long lastMessageTimeMs = 0L;
|
||||
int unreadCount = 0;
|
||||
long lastReadReceiptTimeMs = existingWatermark;
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
lastMessageTimeMs = rs.getLong("time_ms");
|
||||
byte[] rawBlock = rs.getBytes("raw_block");
|
||||
if (rawBlock != null && rawBlock.length > 0) {
|
||||
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong(1);
|
||||
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong(1);
|
||||
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
|
||||
ps.setString(1, cleanPeer);
|
||||
ps.setString(2, cleanOwner);
|
||||
ps.setLong(3, lastReadReceiptTimeMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) unreadCount = rs.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
return new DialogSummary(
|
||||
cleanOwner,
|
||||
cleanPeer,
|
||||
relationFlag,
|
||||
lastMessageBlobB64,
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
lastReadReceiptTimeMs,
|
||||
System.currentTimeMillis(),
|
||||
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
|
||||
);
|
||||
}
|
||||
|
||||
private void upsert(Connection c, DialogSummary summary) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_dialog_state (
|
||||
owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
|
||||
relation_flag = EXCLUDED.relation_flag,
|
||||
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
|
||||
last_message_time_ms = EXCLUDED.last_message_time_ms,
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, summary.ownerLogin());
|
||||
ps.setString(2, summary.peerLogin());
|
||||
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
|
||||
ps.setString(4, summary.lastMessageBlobB64());
|
||||
ps.setLong(5, summary.lastMessageTimeMs());
|
||||
ps.setInt(6, summary.unreadCount());
|
||||
ps.setLong(7, summary.lastReadReceiptTimeMs());
|
||||
ps.setLong(8, summary.updatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
private boolean hasAnyRow(Connection c) throws SQLException {
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
|
||||
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT owner_login, peer_login
|
||||
FROM (
|
||||
SELECT
|
||||
m.target_login AS owner_login,
|
||||
CASE
|
||||
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
|
||||
ELSE m.to_login
|
||||
END AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.from_login AS owner_login, m.to_login AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (5, 6, 7, 8)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.to_login AS owner_login, m.from_login AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (5, 6, 7, 8)
|
||||
) pairs
|
||||
WHERE owner_login IS NOT NULL
|
||||
AND peer_login IS NOT NULL
|
||||
AND BTRIM(owner_login) <> ''
|
||||
AND BTRIM(peer_login) <> ''
|
||||
AND LOWER(owner_login) <> LOWER(peer_login)
|
||||
ORDER BY owner_login, peer_login
|
||||
""";
|
||||
List<PairItem> out = new ArrayList<>();
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
String ownerLogin = rs.getString("owner_login");
|
||||
String peerLogin = rs.getString("peer_login");
|
||||
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
|
||||
out.add(new PairItem(ownerLogin, peerLogin));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
|
||||
String owner = normalize(ownerLogin);
|
||||
String peer = normalize(peerLogin);
|
||||
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
|
||||
String key = normKey(peer);
|
||||
DialogSummary current = map.get(key);
|
||||
if (current == null) {
|
||||
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
|
||||
return;
|
||||
}
|
||||
String nextRelation = current.relationFlag();
|
||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "close_friend";
|
||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "contact";
|
||||
} else {
|
||||
nextRelation = normalizeRelationFlag(nextRelation);
|
||||
}
|
||||
map.put(key, new DialogSummary(
|
||||
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
|
||||
peer,
|
||||
nextRelation,
|
||||
current.lastMessageBlobB64(),
|
||||
current.lastMessageTimeMs(),
|
||||
current.unreadCount(),
|
||||
current.lastReadReceiptTimeMs(),
|
||||
current.updatedAtMs(),
|
||||
current.hasDialog() || hasDialog
|
||||
));
|
||||
}
|
||||
|
||||
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
|
||||
if (watermark <= 0) return;
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_messages
|
||||
SET read_at_ms = CASE
|
||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||
WHEN read_at_ms > ? THEN read_at_ms
|
||||
ELSE read_at_ms
|
||||
END
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
AND time_ms <= ?
|
||||
""")) {
|
||||
ps.setLong(1, watermark);
|
||||
ps.setLong(2, watermark);
|
||||
ps.setString(3, cleanOwner);
|
||||
ps.setString(4, cleanPeer);
|
||||
ps.setString(5, cleanPeer);
|
||||
ps.setString(6, cleanOwner);
|
||||
ps.setLong(7, watermark);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT last_read_receipt_time_ms
|
||||
FROM dm_dialog_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(peer_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, peerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return 0L;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? 0L : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private String normKey(String value) {
|
||||
return normalize(value).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeRelationFlag(String value) {
|
||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
return "none";
|
||||
}
|
||||
|
||||
public record DialogSummary(
|
||||
String ownerLogin,
|
||||
String peerLogin,
|
||||
String relationFlag,
|
||||
String lastMessageBlobB64,
|
||||
long lastMessageTimeMs,
|
||||
int unreadCount,
|
||||
long lastReadReceiptTimeMs,
|
||||
long updatedAtMs,
|
||||
boolean hasDialog
|
||||
) {}
|
||||
|
||||
private record PairItem(String ownerLogin, String peerLogin) {}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DmSyncOutboxEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Outbox событий DM для единственного второго access-сервера пользователя. */
|
||||
public final class DmSyncOutboxDAO {
|
||||
private static volatile DmSyncOutboxDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DmSyncOutboxDAO() {}
|
||||
|
||||
public static DmSyncOutboxDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmSyncOutboxDAO.class) {
|
||||
if (instance == null) instance = new DmSyncOutboxDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void upsert(
|
||||
String ownerLogin,
|
||||
String primaryMessageKey,
|
||||
String eventId,
|
||||
String secondaryMessageKey,
|
||||
boolean synced,
|
||||
long createdAtMs
|
||||
) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
String sql = """
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
) VALUES (LOWER(?), ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
secondary_message_key = EXCLUDED.secondary_message_key,
|
||||
synced = CASE
|
||||
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
|
||||
THEN dm_sync_outbox.synced OR EXCLUDED.synced
|
||||
ELSE EXCLUDED.synced
|
||||
END,
|
||||
created_at_ms = CASE
|
||||
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
|
||||
THEN dm_sync_outbox.created_at_ms
|
||||
ELSE EXCLUDED.created_at_ms
|
||||
END,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, primaryMessageKey);
|
||||
ps.setString(3, eventId);
|
||||
if (secondaryMessageKey == null || secondaryMessageKey.isBlank()) ps.setNull(4, Types.VARCHAR);
|
||||
else ps.setString(4, secondaryMessageKey.trim());
|
||||
ps.setBoolean(5, synced);
|
||||
ps.setLong(6, createdAtMs);
|
||||
ps.setLong(7, now);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<DmSyncOutboxEntry> listUnsynced(String ownerLogin, int limit) throws SQLException {
|
||||
return listUnsynced(ownerLogin, 0L, "", limit);
|
||||
}
|
||||
|
||||
public List<DmSyncOutboxEntry> listUnsynced(
|
||||
String ownerLogin, long afterCreatedAtMs, String afterPrimaryMessageKey, int limit
|
||||
) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
FROM dm_sync_outbox
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND synced = FALSE
|
||||
AND (created_at_ms > ? OR (created_at_ms = ? AND primary_message_key > ?))
|
||||
ORDER BY created_at_ms ASC, primary_message_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<DmSyncOutboxEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setLong(2, Math.max(0L, afterCreatedAtMs));
|
||||
ps.setLong(3, Math.max(0L, afterCreatedAtMs));
|
||||
ps.setString(4, afterPrimaryMessageKey == null ? "" : afterPrimaryMessageKey);
|
||||
ps.setInt(5, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean hasUnsyncedForServer(String serverLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM dm_sync_outbox o
|
||||
JOIN user_access_servers_current r
|
||||
ON LOWER(r.user_login) = LOWER(o.owner_login)
|
||||
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_access_servers_current peer
|
||||
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
|
||||
AND LOWER(peer.server_login) <> LOWER(?)
|
||||
)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, serverLogin);
|
||||
ps.setString(2, serverLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> listUnsyncedOwnersForServer(String serverLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT o.owner_login
|
||||
FROM dm_sync_outbox o
|
||||
JOIN user_access_servers_current r
|
||||
ON LOWER(r.user_login) = LOWER(o.owner_login)
|
||||
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_access_servers_current peer
|
||||
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
|
||||
AND LOWER(peer.server_login) <> LOWER(?)
|
||||
)
|
||||
ORDER BY o.owner_login
|
||||
""";
|
||||
List<String> result = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, serverLogin);
|
||||
ps.setString(2, serverLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(rs.getString("owner_login"));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public int markSynced(String ownerLogin, String eventId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_sync_outbox
|
||||
SET synced = TRUE, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, eventId);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markSynced(String ownerLogin, List<String> eventIds) throws SQLException {
|
||||
if (eventIds == null || eventIds.isEmpty()) return 0;
|
||||
int total = 0;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE dm_sync_outbox
|
||||
SET synced = TRUE, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
|
||||
""")) {
|
||||
long now = System.currentTimeMillis();
|
||||
for (String eventId : eventIds) {
|
||||
if (eventId == null || eventId.isBlank()) continue;
|
||||
ps.setLong(1, now);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, eventId.trim());
|
||||
ps.addBatch();
|
||||
}
|
||||
for (int changed : ps.executeBatch()) if (changed > 0) total += changed;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public int markAllUnsynced(String ownerLogin) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_sync_outbox
|
||||
SET synced = FALSE, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
ps.setString(2, ownerLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced() throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("UPDATE dm_sync_outbox SET synced = FALSE, updated_at_ms = ?")) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeMissingMessages() throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM dm_sync_outbox o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM signed_messages m WHERE m.message_key = o.primary_message_key
|
||||
)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private DmSyncOutboxEntry mapRow(ResultSet rs) throws SQLException {
|
||||
DmSyncOutboxEntry e = new DmSyncOutboxEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setPrimaryMessageKey(rs.getString("primary_message_key"));
|
||||
e.setEventId(rs.getString("event_id"));
|
||||
e.setSecondaryMessageKey(rs.getString("secondary_message_key"));
|
||||
e.setSynced(rs.getBoolean("synced"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ public final class SignedMessagesDAO {
|
||||
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
if (status.applied()) {
|
||||
markMessageReadByReceipt(c, e);
|
||||
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
markMessageReadByReceipt(c, first);
|
||||
markMessageReadByReceipt(c, second);
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
|
||||
markMessageReadByReceipt(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
|
||||
upsertMessage(c, incoming);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
|
||||
+29
@@ -75,6 +75,35 @@ public final class UserAccessServersCurrentDAO {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает уникальные физические серверы из текущей routing-проекции.
|
||||
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
|
||||
* а не на каждого пользователя этого сервера.
|
||||
*/
|
||||
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (LOWER(server_login))
|
||||
server_login, server_url
|
||||
FROM user_access_servers_current
|
||||
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
|
||||
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
|
||||
ORDER BY LOWER(server_login), server_login, server_url
|
||||
""";
|
||||
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||
entry.setUserLogin("");
|
||||
entry.setServerLogin(rs.getString("server_login"));
|
||||
entry.setServerUrl(rs.getString("server_url"));
|
||||
result.add(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||
entry.setUserLogin(rs.getString("user_login"));
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class UserNotificationsStateDAO {
|
||||
private static volatile UserNotificationsStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserNotificationsStateDAO() {}
|
||||
|
||||
public static UserNotificationsStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserNotificationsStateDAO.class) {
|
||||
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_notifications_state (
|
||||
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||
created_at_ms = EXCLUDED.created_at_ms,
|
||||
source_login = EXCLUDED.source_login,
|
||||
target_login = EXCLUDED.target_login,
|
||||
target_bch_name = EXCLUDED.target_bch_name,
|
||||
target_block_number = EXCLUDED.target_block_number,
|
||||
target_block_hash = EXCLUDED.target_block_hash,
|
||||
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||
source_text = EXCLUDED.source_text
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, e.getOwnerLogin());
|
||||
ps.setString(i++, e.getNotificationKind());
|
||||
ps.setLong(i++, e.getCreatedAtMs());
|
||||
ps.setString(i++, e.getSourceLogin());
|
||||
ps.setString(i++, e.getSourceBchName());
|
||||
ps.setInt(i++, e.getSourceBlockNumber());
|
||||
ps.setBytes(i++, e.getSourceBlockHash());
|
||||
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||
ps.setInt(i++, e.getSourceMsgSubType());
|
||||
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, kind);
|
||||
ps.setInt(3, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setNotificationKind(rs.getString("notification_kind"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceLogin(rs.getString("source_login"));
|
||||
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||
e.setSourceText(rs.getString("source_text"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmDeliveryStateEntry {
|
||||
public static final int ACCEPTED = 0;
|
||||
public static final int DELIVERED = 1;
|
||||
public static final int FAILED = 3;
|
||||
|
||||
// Совместимость со строками БД, созданными ранней экспериментальной миграцией.
|
||||
public static final int PENDING_NONE = ACCEPTED;
|
||||
public static final int DELIVERED_ONE = DELIVERED;
|
||||
public static final int DELIVERED_ALL = 2;
|
||||
public static final int FAILED_FINAL = FAILED;
|
||||
|
||||
private String outgoingMessageKey;
|
||||
private String eventId;
|
||||
private String baseKey;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String incomingMessageKey;
|
||||
private long createdAtMs;
|
||||
private long deliveryExpiresAtMs;
|
||||
private int deliveryState;
|
||||
private String deliveredServerLogin;
|
||||
private String recipientRoutesHash;
|
||||
private int attemptIndex;
|
||||
private Long nextAttemptAtMs;
|
||||
private Long lastAttemptAtMs;
|
||||
private String lastError;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOutgoingMessageKey() { return outgoingMessageKey; }
|
||||
public void setOutgoingMessageKey(String value) { this.outgoingMessageKey = value; }
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String value) { this.eventId = value; }
|
||||
public String getBaseKey() { return baseKey; }
|
||||
public void setBaseKey(String value) { this.baseKey = value; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String value) { this.fromLogin = value; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String value) { this.toLogin = value; }
|
||||
public String getIncomingMessageKey() { return incomingMessageKey; }
|
||||
public void setIncomingMessageKey(String value) { this.incomingMessageKey = value; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
|
||||
public long getDeliveryExpiresAtMs() { return deliveryExpiresAtMs; }
|
||||
public void setDeliveryExpiresAtMs(long value) { this.deliveryExpiresAtMs = value; }
|
||||
public int getDeliveryState() { return deliveryState; }
|
||||
public void setDeliveryState(int value) { this.deliveryState = value; }
|
||||
public String getDeliveredServerLogin() { return deliveredServerLogin; }
|
||||
public void setDeliveredServerLogin(String value) { this.deliveredServerLogin = value; }
|
||||
public String getRecipientRoutesHash() { return recipientRoutesHash; }
|
||||
public void setRecipientRoutesHash(String value) { this.recipientRoutesHash = value; }
|
||||
public int getAttemptIndex() { return attemptIndex; }
|
||||
public void setAttemptIndex(int value) { this.attemptIndex = value; }
|
||||
public Long getNextAttemptAtMs() { return nextAttemptAtMs; }
|
||||
public void setNextAttemptAtMs(Long value) { this.nextAttemptAtMs = value; }
|
||||
public Long getLastAttemptAtMs() { return lastAttemptAtMs; }
|
||||
public void setLastAttemptAtMs(Long value) { this.lastAttemptAtMs = value; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String value) { this.lastError = value; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
|
||||
|
||||
public String deliveryStateCode() {
|
||||
return switch (deliveryState) {
|
||||
case DELIVERED_ONE, DELIVERED_ALL -> "delivered";
|
||||
case FAILED_FINAL -> "failed";
|
||||
default -> "accepted";
|
||||
};
|
||||
}
|
||||
|
||||
public static int parseStateCode(String value) {
|
||||
if (value == null) return ACCEPTED;
|
||||
return switch (value.trim().toLowerCase()) {
|
||||
case "delivered", "delivered_one", "delivered_all" -> DELIVERED;
|
||||
case "failed", "failed_final" -> FAILED;
|
||||
default -> ACCEPTED;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean isDelivered() {
|
||||
return deliveryState == DELIVERED_ONE || deliveryState == DELIVERED_ALL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmSyncOutboxEntry {
|
||||
private String ownerLogin;
|
||||
private String primaryMessageKey;
|
||||
private String eventId;
|
||||
private String secondaryMessageKey;
|
||||
private boolean synced;
|
||||
private long createdAtMs;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String value) { this.ownerLogin = value; }
|
||||
public String getPrimaryMessageKey() { return primaryMessageKey; }
|
||||
public void setPrimaryMessageKey(String value) { this.primaryMessageKey = value; }
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String value) { this.eventId = value; }
|
||||
public String getSecondaryMessageKey() { return secondaryMessageKey; }
|
||||
public void setSecondaryMessageKey(String value) { this.secondaryMessageKey = value; }
|
||||
public boolean isSynced() { return synced; }
|
||||
public void setSynced(boolean value) { this.synced = value; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserNotificationEntry {
|
||||
private String ownerLogin;
|
||||
private String notificationKind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBchName;
|
||||
private int sourceBlockNumber;
|
||||
private byte[] sourceBlockHash;
|
||||
private String targetLogin;
|
||||
private String targetBchName;
|
||||
private Integer targetBlockNumber;
|
||||
private byte[] targetBlockHash;
|
||||
private int sourceMsgSubType;
|
||||
private String sourceText;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getNotificationKind() { return notificationKind; }
|
||||
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBchName() { return sourceBchName; }
|
||||
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBchName() { return targetBchName; }
|
||||
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
|
||||
source_block_hash BYTEA NOT NULL,
|
||||
target_login TEXT,
|
||||
target_bch_name TEXT,
|
||||
target_block_number INTEGER,
|
||||
target_block_hash BYTEA,
|
||||
source_msg_sub_type INTEGER NOT NULL,
|
||||
source_text TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
|
||||
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
|
||||
ON user_notifications_state(owner_login, created_at_ms DESC);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,30 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_dialog_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
peer_login TEXT NOT NULL,
|
||||
relation_flag TEXT NOT NULL DEFAULT 'none',
|
||||
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
|
||||
last_message_key TEXT NOT NULL DEFAULT '',
|
||||
last_message_type INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, peer_login)
|
||||
);
|
||||
|
||||
ALTER TABLE IF EXISTS dm_dialog_state
|
||||
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
|
||||
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 11, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,139 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_delivery_state (
|
||||
outgoing_message_key TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
base_key TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
incoming_message_key TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
delivery_expires_at_ms BIGINT NOT NULL,
|
||||
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
|
||||
delivered_server_login TEXT,
|
||||
recipient_routes_hash TEXT,
|
||||
attempt_index INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at_ms BIGINT,
|
||||
last_attempt_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
|
||||
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
|
||||
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
|
||||
ON dm_delivery_state(base_key, from_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
|
||||
owner_login TEXT NOT NULL,
|
||||
primary_message_key TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
secondary_message_key TEXT,
|
||||
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, primary_message_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
|
||||
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
|
||||
WHERE synced = FALSE;
|
||||
|
||||
-- До v12 факт межсерверной доставки не сохранялся. Старые исходящие пары
|
||||
-- считаем уже доставленными, чтобы обновление не вызвало повторную рассылку.
|
||||
INSERT INTO dm_delivery_state (
|
||||
outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
outgoing.message_key,
|
||||
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
|
||||
outgoing.base_key,
|
||||
outgoing.from_login,
|
||||
outgoing.to_login,
|
||||
incoming.message_key,
|
||||
outgoing.created_at_ms,
|
||||
outgoing.created_at_ms + 3600000,
|
||||
2,
|
||||
NULL,
|
||||
NULL,
|
||||
9,
|
||||
NULL,
|
||||
NULL,
|
||||
'MIGRATED_AS_DELIVERED',
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages outgoing
|
||||
JOIN signed_messages incoming
|
||||
ON incoming.base_key = outgoing.base_key
|
||||
AND incoming.message_type = outgoing.message_type - 1
|
||||
WHERE outgoing.message_type IN (2, 4)
|
||||
ON CONFLICT (outgoing_message_key) DO NOTHING;
|
||||
|
||||
-- Историю до v12 считаем подтверждённой, иначе само обновление вызовет
|
||||
-- массовую повторную передачу. При замене сервера общий reset вернёт FALSE.
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
LOWER(outgoing.from_login),
|
||||
outgoing.message_key,
|
||||
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
|
||||
incoming.message_key,
|
||||
TRUE,
|
||||
outgoing.created_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages outgoing
|
||||
JOIN signed_messages incoming
|
||||
ON incoming.base_key = outgoing.base_key
|
||||
AND incoming.message_type = outgoing.message_type - 1
|
||||
WHERE outgoing.message_type IN (2, 4)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||
|
||||
-- Входящая копия синхронизируется отдельно между серверами получателя.
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
LOWER(incoming.to_login),
|
||||
incoming.message_key,
|
||||
incoming.message_key || ':' || incoming.revision_time_ms || ':' || incoming.reencrypted_at_ms,
|
||||
NULL,
|
||||
TRUE,
|
||||
incoming.created_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages incoming
|
||||
WHERE incoming.message_type IN (1, 3)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||
|
||||
-- Tombstone принадлежит обоим участникам и должен попасть на второй сервер
|
||||
-- каждого из них. Для self-DM конфликт безопасно схлопывается.
|
||||
INSERT INTO dm_sync_outbox (
|
||||
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||
synced, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
LOWER(owner_login),
|
||||
tombstone.message_key,
|
||||
tombstone.message_key || ':' || tombstone.revision_time_ms || ':' || tombstone.reencrypted_at_ms,
|
||||
NULL,
|
||||
TRUE,
|
||||
tombstone.created_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM signed_messages tombstone
|
||||
CROSS JOIN LATERAL (VALUES (tombstone.from_login), (tombstone.to_login)) owners(owner_login)
|
||||
WHERE tombstone.message_type IN (5, 6, 7, 8)
|
||||
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 12, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,46 @@
|
||||
BEGIN;
|
||||
|
||||
-- Нормализация экспериментальной схемы доставки v12:
|
||||
-- 0=accepted, 1=delivered, 3=failed. Старое delivered_all (2)
|
||||
-- объединяется с delivered, поскольку ACK одного сервера теперь достаточен.
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_state = 1,
|
||||
delivered_server_login = NULL,
|
||||
next_attempt_at_ms = NULL,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE delivery_state = 2;
|
||||
|
||||
-- Для незавершённых записей перестраиваем только будущую очередь. Повторная
|
||||
-- передача безопасна благодаря messageKey и идемпотентному приёму.
|
||||
UPDATE dm_delivery_state
|
||||
SET delivery_expires_at_ms = created_at_ms + 3600000,
|
||||
attempt_index = CASE
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000 THEN 4
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000 THEN 3
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000 THEN 2
|
||||
ELSE 1
|
||||
END,
|
||||
next_attempt_at_ms = CASE
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000
|
||||
THEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000
|
||||
THEN created_at_ms + 3600000
|
||||
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000
|
||||
THEN created_at_ms + 1500000
|
||||
ELSE created_at_ms + 30000
|
||||
END,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE delivery_state = 0;
|
||||
|
||||
DROP INDEX IF EXISTS idx_dm_delivery_state_due;
|
||||
CREATE INDEX idx_dm_delivery_state_due
|
||||
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
|
||||
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 13, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,620 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_stats_state (
|
||||
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
|
||||
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
|
||||
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
|
||||
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
|
||||
ON user_stats_state (following_users_count);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_stats_state (
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
|
||||
channel_root_block_hash BYTEA NOT NULL,
|
||||
owner_login TEXT NOT NULL,
|
||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
|
||||
ON channel_stats_state (owner_login);
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
now_ms BIGINT;
|
||||
BEGIN
|
||||
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (login) DO NOTHING;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
now_ms BIGINT;
|
||||
public_subscribers_count INTEGER;
|
||||
BEGIN
|
||||
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
|
||||
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_login,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
SELECT COUNT(*)::INTEGER
|
||||
INTO public_subscribers_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.owner_bch_name = NEW.owner_bch_name
|
||||
AND cn.channel_root_block_number = NEW.channel_root_block_number
|
||||
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
|
||||
AND cn.channel_type_code = 1;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_bch_name,
|
||||
NEW.channel_root_block_number,
|
||||
NEW.channel_root_block_hash,
|
||||
NEW.owner_login,
|
||||
NEW.channel_type_code,
|
||||
COALESCE(public_subscribers_count, 0),
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = EXCLUDED.subscribers_count,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
WITH pending AS (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type = 30
|
||||
AND cs.to_bch_name = NEW.owner_bch_name
|
||||
AND cs.to_block_number = NEW.channel_root_block_number
|
||||
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||
GROUP BY cs.login
|
||||
)
|
||||
UPDATE user_stats_state us
|
||||
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||
updated_at_ms = now_ms
|
||||
FROM pending
|
||||
WHERE us.login = pending.login;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_type INTEGER;
|
||||
existed_before BOOLEAN;
|
||||
target_channel_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type <> 3 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
|
||||
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
)
|
||||
INTO existed_before;
|
||||
|
||||
IF NOT existed_before THEN
|
||||
IF NEW.msg_sub_type = 10 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
close_friends_count = user_stats_state.close_friends_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.msg_sub_type = 30 THEN
|
||||
IF NEW.to_block_number IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.to_block_number = 0 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_users_count = user_stats_state.following_users_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
SELECT cn.channel_type_code
|
||||
INTO target_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||
AND cn.channel_root_block_number = NEW.to_block_number
|
||||
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF target_channel_type = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash,
|
||||
resolved_login,
|
||||
target_channel_type,
|
||||
1,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = channel_stats_state.subscribers_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF target_channel_type IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||
|
||||
INSERT INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
NEW.msg_sub_type,
|
||||
resolved_login,
|
||||
NEW.to_bch_name,
|
||||
COALESCE(NEW.to_block_number, 0),
|
||||
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
positive_rel_type := CASE NEW.msg_sub_type
|
||||
WHEN 11 THEN 10
|
||||
WHEN 21 THEN 20
|
||||
WHEN 31 THEN 30
|
||||
WHEN 41 THEN 40
|
||||
WHEN 51 THEN 50
|
||||
WHEN 53 THEN 52
|
||||
WHEN 55 THEN 54
|
||||
WHEN 61 THEN 60
|
||||
WHEN 71 THEN 70
|
||||
WHEN 75 THEN 74
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = positive_rel_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
)
|
||||
INTO existed_before;
|
||||
|
||||
IF NOT existed_before THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF positive_rel_type = 10 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF positive_rel_type = 30 THEN
|
||||
IF NEW.to_block_number IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.to_block_number = 0 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
SELECT cn.channel_type_code
|
||||
INTO target_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||
AND cn.channel_root_block_number = NEW.to_block_number
|
||||
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF target_channel_type = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash,
|
||||
resolved_login,
|
||||
target_channel_type,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF target_channel_type IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = positive_rel_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||
AFTER INSERT ON channel_names_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
|
||||
CREATE TRIGGER trg_blocks_connection_state_ai
|
||||
AFTER INSERT ON blocks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_blocks_connection_state_ai();
|
||||
|
||||
TRUNCATE TABLE user_stats_state, channel_stats_state;
|
||||
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
u.login,
|
||||
COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count,
|
||||
COALESCE(fu.following_users_count, 0) AS following_users_count,
|
||||
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
|
||||
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
|
||||
FROM solana_user_pda_current u
|
||||
LEFT JOIN (
|
||||
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
|
||||
FROM channel_names_state
|
||||
WHERE channel_type_code = 1
|
||||
GROUP BY owner_login
|
||||
) own ON LOWER(own.owner_login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT login, COUNT(*)::INTEGER AS following_users_count
|
||||
FROM connections_state
|
||||
WHERE rel_type = 30
|
||||
AND to_block_number = 0
|
||||
GROUP BY login
|
||||
) fu ON LOWER(fu.login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.channel_type_code = 1
|
||||
GROUP BY cs.login
|
||||
) fc ON LOWER(fc.login) = LOWER(u.login)
|
||||
LEFT JOIN (
|
||||
SELECT login, COUNT(*)::INTEGER AS close_friends_count
|
||||
FROM connections_state
|
||||
WHERE rel_type = 10
|
||||
GROUP BY login
|
||||
) cf ON LOWER(cf.login) = LOWER(u.login);
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
cn.owner_bch_name,
|
||||
cn.channel_root_block_number,
|
||||
cn.channel_root_block_hash,
|
||||
cn.owner_login,
|
||||
cn.channel_type_code,
|
||||
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
|
||||
FROM channel_names_state cn
|
||||
LEFT JOIN connections_state cs
|
||||
ON cs.rel_type = 30
|
||||
AND cs.to_bch_name = cn.owner_bch_name
|
||||
AND cs.to_block_number = cn.channel_root_block_number
|
||||
AND cs.to_block_hash = cn.channel_root_block_hash
|
||||
WHERE cn.channel_type_code = 1
|
||||
GROUP BY
|
||||
cn.owner_bch_name,
|
||||
cn.channel_root_block_number,
|
||||
cn.channel_root_block_hash,
|
||||
cn.owner_login,
|
||||
cn.channel_type_code;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 14, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,105 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
now_ms BIGINT;
|
||||
public_subscribers_count INTEGER;
|
||||
BEGIN
|
||||
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
|
||||
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM solana_user_pda_current su
|
||||
WHERE su.login = NEW.owner_login
|
||||
) THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_login,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*)::INTEGER
|
||||
INTO public_subscribers_count
|
||||
FROM connections_state cs
|
||||
JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name = cs.to_bch_name
|
||||
AND cn.channel_root_block_number = cs.to_block_number
|
||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||
WHERE cs.rel_type = 30
|
||||
AND cn.owner_bch_name = NEW.owner_bch_name
|
||||
AND cn.channel_root_block_number = NEW.channel_root_block_number
|
||||
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
|
||||
AND cn.channel_type_code = 1;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.owner_bch_name,
|
||||
NEW.channel_root_block_number,
|
||||
NEW.channel_root_block_hash,
|
||||
NEW.owner_login,
|
||||
NEW.channel_type_code,
|
||||
COALESCE(public_subscribers_count, 0),
|
||||
now_ms
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = EXCLUDED.subscribers_count,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
WITH pending AS (
|
||||
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type = 30
|
||||
AND cs.to_bch_name = NEW.owner_bch_name
|
||||
AND cs.to_block_number = NEW.channel_root_block_number
|
||||
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||
GROUP BY cs.login
|
||||
)
|
||||
UPDATE user_stats_state us
|
||||
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||
updated_at_ms = now_ms
|
||||
FROM pending
|
||||
WHERE us.login = pending.login;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 15, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -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, 9, 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;
|
||||
@@ -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,
|
||||
@@ -751,6 +783,47 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
ON signed_message_session_delivery(session_id, delivered);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
|
||||
source_block_hash BYTEA NOT NULL,
|
||||
target_login TEXT,
|
||||
target_bch_name TEXT,
|
||||
target_block_number INTEGER,
|
||||
target_block_hash BYTEA,
|
||||
source_msg_sub_type INTEGER NOT NULL,
|
||||
source_text TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
|
||||
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
|
||||
ON user_notifications_state(owner_login, created_at_ms DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_dialog_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
peer_login TEXT NOT NULL,
|
||||
relation_flag TEXT NOT NULL DEFAULT 'none',
|
||||
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
|
||||
last_message_key TEXT NOT NULL DEFAULT '',
|
||||
last_message_type INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, peer_login)
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -767,6 +840,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,
|
||||
@@ -835,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
|
||||
@@ -913,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;
|
||||
@@ -925,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
|
||||
@@ -962,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;
|
||||
@@ -1145,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;
|
||||
|
||||
+26
@@ -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<String> 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<String> getRemoteServerCapabilities() { return remoteServerCapabilities; }
|
||||
public void setRemoteServerCapabilities(List<String> 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
|
||||
|
||||
+12
@@ -88,14 +88,17 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_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;
|
||||
@@ -110,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;
|
||||
@@ -129,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;
|
||||
@@ -137,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;
|
||||
|
||||
@@ -207,6 +213,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -217,6 +224,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()),
|
||||
@@ -226,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()),
|
||||
@@ -296,6 +305,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
@@ -306,6 +316,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),
|
||||
@@ -315,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),
|
||||
|
||||
+69
-2
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.UserParamBody;
|
||||
import org.slf4j.Logger;
|
||||
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(
|
||||
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
|
||||
|
||||
public Net_AddBlock_Handler() {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
@@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return b;
|
||||
}
|
||||
|
||||
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
|
||||
if (block == null || storedEntry == null) return null;
|
||||
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
|
||||
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
long createdAtMs = block.timestamp * 1000L;
|
||||
String ownerLogin = storedEntry.getToLogin();
|
||||
String sourceLogin = storedEntry.getLogin();
|
||||
|
||||
// A user must never receive a notification for replying to their own message/thread.
|
||||
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
|
||||
|
||||
if (msgType == 1
|
||||
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
&& block.body instanceof TextReplyBody replyBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("reply");
|
||||
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
|
||||
String ownerLogin,
|
||||
long createdAtMs,
|
||||
int msgSubType) {
|
||||
UserNotificationEntry entry = new UserNotificationEntry();
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setCreatedAtMs(createdAtMs);
|
||||
entry.setSourceLogin(storedEntry.getLogin());
|
||||
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
|
||||
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
|
||||
entry.setSourceBlockHash(storedEntry.getBlockHash());
|
||||
entry.setTargetLogin(storedEntry.getToLogin());
|
||||
entry.setTargetBchName(storedEntry.getToBchName());
|
||||
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
|
||||
entry.setTargetBlockHash(storedEntry.getToBlockHash());
|
||||
entry.setSourceMsgSubType(msgSubType);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private String blockchainNameFromEntry(BlockEntry entry) {
|
||||
return entry == null ? null : entry.getBchName();
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
|
||||
+14
-2
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
UserParamsDAO userParamsDAO,
|
||||
ChannelNameStateDAO channelNameStateDAO) {
|
||||
ChannelNameStateDAO channelNameStateDAO,
|
||||
UserNotificationsStateDAO userNotificationsStateDAO) {
|
||||
this.blocksDAO = blocksDAO;
|
||||
this.stateDAO = stateDAO;
|
||||
this.userParamsDAO = userParamsDAO;
|
||||
this.channelNameStateDAO = channelNameStateDAO;
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelMetaUpdateEntry,
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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; }
|
||||
}
|
||||
|
||||
+20
-4
@@ -8,10 +8,10 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ConnectionsStateDAO;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
@@ -23,14 +23,30 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT);
|
||||
List<DmDialogStateDAO.DialogSummary> dialogs = DmDialogStateDAO.getInstance().listInboxDialogs(c, ctx.getLogin());
|
||||
Net_ListContacts_Response resp = new Net_ListContacts_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(ctx.getLogin());
|
||||
resp.setContacts(contacts);
|
||||
resp.setDialogs(toDialogItems(dialogs));
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
|
||||
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
|
||||
if (dialogs == null) return items;
|
||||
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
|
||||
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
|
||||
item.setPeerLogin(dialog.peerLogin());
|
||||
item.setRelationFlag(dialog.relationFlag());
|
||||
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
|
||||
item.setUnreadCount(dialog.unreadCount());
|
||||
item.setHasDialog(dialog.hasDialog());
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-3
@@ -7,10 +7,32 @@ import java.util.List;
|
||||
|
||||
public class Net_ListContacts_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> contacts = new ArrayList<>();
|
||||
private List<DialogItem> dialogs = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<String> getContacts() { return contacts; }
|
||||
public void setContacts(List<String> contacts) { this.contacts = contacts; }
|
||||
public List<DialogItem> getDialogs() { return dialogs; }
|
||||
public void setDialogs(List<DialogItem> dialogs) { this.dialogs = dialogs; }
|
||||
|
||||
public static class DialogItem {
|
||||
private String peerLogin;
|
||||
private String relationFlag;
|
||||
private String lastMessageBlobB64;
|
||||
private long lastMessageTimeMs;
|
||||
private int unreadCount;
|
||||
private boolean hasDialog;
|
||||
|
||||
public String getPeerLogin() { return peerLogin; }
|
||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||
public String getRelationFlag() { return relationFlag; }
|
||||
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
|
||||
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
|
||||
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
|
||||
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
|
||||
public void setLastMessageTimeMs(long lastMessageTimeMs) { this.lastMessageTimeMs = lastMessageTimeMs; }
|
||||
public int getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
public boolean isHasDialog() { return hasDialog; }
|
||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetNotifications_Request extends Net_Request {
|
||||
private Integer limit;
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetNotifications_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> getEvents() { return events; }
|
||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||
|
||||
public static class NotificationItem {
|
||||
private String kind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBlockchainName;
|
||||
private int sourceBlockNumber;
|
||||
private String sourceBlockHash;
|
||||
private Integer sourceMsgSubType;
|
||||
private Integer connectionTypeCode;
|
||||
private String sourceText;
|
||||
private String targetLogin;
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public String getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public Integer getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public Integer getConnectionTypeCode() { return connectionTypeCode; }
|
||||
public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBlockchainName() { return targetBlockchainName; }
|
||||
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public String getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
}
|
||||
}
|
||||
+70
@@ -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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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<String> 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<String> getCapabilities() { return capabilities; }
|
||||
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
|
||||
}
|
||||
+24
@@ -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<String> 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<String> getCapabilities() { return capabilities; }
|
||||
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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; }
|
||||
|
||||
}
|
||||
|
||||
+9
-5
@@ -74,10 +74,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
||||
+ escapePart(valueText) + '|'
|
||||
+ valueNum;
|
||||
|
||||
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
|
||||
}
|
||||
|
||||
DbController db = DbController.getInstance();
|
||||
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
||||
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
||||
@@ -95,6 +91,14 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
||||
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);
|
||||
}
|
||||
|
||||
UserSettingEntry entry = new UserSettingEntry(
|
||||
login,
|
||||
settingType,
|
||||
@@ -119,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));
|
||||
|
||||
+12
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-24
@@ -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<DmSyncOutboxEntry> 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<Net_DmSyncBatch_Response.Item> 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<String> 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;
|
||||
}
|
||||
|
||||
+13
@@ -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<String, DmDeliveryStateEntry> 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);
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
+23
-5
@@ -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());
|
||||
|
||||
+53
-8
@@ -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,9 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
|
||||
SignedMessageEntry incomingEntry;
|
||||
SignedMessageEntry outgoingEntry;
|
||||
boolean fromPeer = !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 +85,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 +143,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;
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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<String> 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<String> getAckSyncIds() { return ackSyncIds; }
|
||||
public void setAckSyncIds(List<String> ackSyncIds) {
|
||||
this.ackSyncIds = ackSyncIds == null ? new ArrayList<>() : ackSyncIds;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-24
@@ -30,33 +30,20 @@ public class Net_DmSyncBatch_Response extends Net_Response {
|
||||
public void setItems(List<Item> 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<String> 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<String> getBlobsB64() { return blobsB64; }
|
||||
public void setBlobsB64(List<String> blobsB64) {
|
||||
this.blobsB64 = blobsB64 == null ? new ArrayList<>() : blobsB64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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; }
|
||||
}
|
||||
+16
@@ -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; }
|
||||
}
|
||||
+3
@@ -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; }
|
||||
}
|
||||
|
||||
+3
@@ -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; }
|
||||
}
|
||||
|
||||
+16
-126
@@ -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<String> 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<String> responseFuture;
|
||||
private final CountDownLatch openLatch;
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
|
||||
private SyncWsListener(CompletableFuture<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
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<DmDeliveryStateEntry> 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<UserAccessServerRouteEntry> routes = cappedRoutes(current.getToLogin());
|
||||
String routesHash = routesHash(routes);
|
||||
String alreadyDelivered = normalize(current.getDeliveredServerLogin());
|
||||
List<String> 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<RouteAttempt> 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;
|
||||
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());
|
||||
}
|
||||
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<UserAccessServerRouteEntry> cappedRoutes(String ownerLogin) throws Exception {
|
||||
Map<String, UserAccessServerRouteEntry> 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<UserAccessServerRouteEntry> routes) throws Exception {
|
||||
List<String> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -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) {
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
-132
@@ -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<RemoteBlockchainHead> listBlockchainHeads(String serverAddressRaw) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
return listBlockchainHeads(null, serverAddressRaw);
|
||||
}
|
||||
|
||||
public List<RemoteBlockchainHead> 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<String> 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<String> responseFuture;
|
||||
private final CountDownLatch openLatch;
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
|
||||
private SyncWsListener(CompletableFuture<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
-131
@@ -3,31 +3,33 @@ package server.sync;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Клиент стабильных межсерверных DM-операций. */
|
||||
public final class RemoteDmSyncClient {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
|
||||
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception {
|
||||
public void sendMessagePair(
|
||||
String serverAddressRaw,
|
||||
String incomingBlobB64,
|
||||
String outgoingBlobB64,
|
||||
String sourceServerLogin
|
||||
) throws Exception {
|
||||
sendMessagePair(null, serverAddressRaw, incomingBlobB64, outgoingBlobB64, sourceServerLogin);
|
||||
}
|
||||
|
||||
public void sendMessagePair(
|
||||
String targetServerLogin,
|
||||
String serverAddressRaw,
|
||||
String incomingBlobB64,
|
||||
String outgoingBlobB64,
|
||||
String sourceServerLogin
|
||||
) throws Exception {
|
||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||
{
|
||||
"op":"ReceiveOutcomingMessage",
|
||||
"requestId":%s,
|
||||
@@ -40,10 +42,23 @@ public final class RemoteDmSyncClient {
|
||||
ensureOk("ReceiveOutcomingMessage", response);
|
||||
}
|
||||
|
||||
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception {
|
||||
public void receiveIncomingMessage(
|
||||
String serverAddressRaw,
|
||||
String incomingBlobB64,
|
||||
String sourceServerLogin
|
||||
) throws Exception {
|
||||
receiveIncomingMessage(null, serverAddressRaw, incomingBlobB64, sourceServerLogin);
|
||||
}
|
||||
|
||||
public void receiveIncomingMessage(
|
||||
String targetServerLogin,
|
||||
String serverAddressRaw,
|
||||
String incomingBlobB64,
|
||||
String sourceServerLogin
|
||||
) throws Exception {
|
||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||
{
|
||||
"op":"ReceiveIncomingMessage",
|
||||
"requestId":%s,
|
||||
@@ -55,17 +70,74 @@ public final class RemoteDmSyncClient {
|
||||
ensureOk("ReceiveIncomingMessage", response);
|
||||
}
|
||||
|
||||
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
|
||||
return getDmDeliveryStatus(null, serverAddressRaw, messageKey);
|
||||
}
|
||||
|
||||
public RemoteDeliveryStatus getDmDeliveryStatus(
|
||||
String targetServerLogin, String serverAddressRaw, String messageKey
|
||||
) throws Exception {
|
||||
String messageKeyJson = MAPPER.writeValueAsString(messageKey);
|
||||
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||
{
|
||||
"op":"GetDmDeliveryStatus",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"messageKey":%s
|
||||
}
|
||||
}
|
||||
""".formatted("%s", messageKeyJson));
|
||||
ensureOk("GetDmDeliveryStatus", response);
|
||||
JsonNode payload = response.path("payload");
|
||||
return new RemoteDeliveryStatus(
|
||||
payload.path("messageKey").asText(messageKey),
|
||||
payload.path("known").asBoolean(false),
|
||||
payload.path("delivered").asBoolean(false)
|
||||
);
|
||||
}
|
||||
|
||||
public RemoteDmBatch dmSyncBatch(
|
||||
String serverAddressRaw,
|
||||
String ownerLogin,
|
||||
long afterStoredAtMs,
|
||||
String afterMessageKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
int maxBytes,
|
||||
List<String> ackSyncIds
|
||||
) throws Exception {
|
||||
return dmSyncBatch(null, serverAddressRaw, ownerLogin, afterStoredAtMs,
|
||||
afterMessageKey, limit, maxBytes, ackSyncIds);
|
||||
}
|
||||
|
||||
public RemoteDmBatch dmSyncBatch(
|
||||
String targetServerLogin,
|
||||
String serverAddressRaw,
|
||||
String ownerLogin,
|
||||
long afterStoredAtMs,
|
||||
String afterMessageKey,
|
||||
int limit,
|
||||
int maxBytes,
|
||||
List<String> ackSyncIds
|
||||
) throws Exception {
|
||||
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
|
||||
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
|
||||
limit, maxBytes, ackSyncIds);
|
||||
}
|
||||
}
|
||||
|
||||
public RemoteDmBatch dmSyncBatch(
|
||||
RemoteSyncSession session,
|
||||
String ownerLogin,
|
||||
long afterStoredAtMs,
|
||||
String afterMessageKey,
|
||||
int limit,
|
||||
int maxBytes,
|
||||
List<String> ackSyncIds
|
||||
) throws Exception {
|
||||
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
|
||||
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
String ackSyncIdsJson = MAPPER.writeValueAsString(ackSyncIds == null ? List.of() : ackSyncIds);
|
||||
JsonNode response = session.send("""
|
||||
{
|
||||
"op":"DmSyncBatch",
|
||||
"requestId":%s,
|
||||
@@ -74,10 +146,12 @@ public final class RemoteDmSyncClient {
|
||||
"afterStoredAtMs":%d,
|
||||
"afterMessageKey":%s,
|
||||
"limit":%d,
|
||||
"maxBytes":%d
|
||||
"maxBytes":%d,
|
||||
"ackSyncIds":%s
|
||||
}
|
||||
}
|
||||
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
|
||||
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson,
|
||||
limit, maxBytes, ackSyncIdsJson));
|
||||
ensureOk("DmSyncBatch", response);
|
||||
|
||||
JsonNode payload = response.path("payload");
|
||||
@@ -85,11 +159,17 @@ public final class RemoteDmSyncClient {
|
||||
JsonNode arr = payload.path("items");
|
||||
if (arr.isArray()) {
|
||||
for (JsonNode item : arr) {
|
||||
List<String> blobs = new ArrayList<>();
|
||||
JsonNode blobsNode = item.path("blobsB64");
|
||||
if (blobsNode.isArray()) for (JsonNode blob : blobsNode) blobs.add(blob.asText(""));
|
||||
if (blobs.isEmpty() && !item.path("blobB64").asText("").isBlank()) {
|
||||
blobs.add(item.path("blobB64").asText(""));
|
||||
}
|
||||
items.add(new RemoteDmItem(
|
||||
item.path("messageKey").asText(""),
|
||||
item.path("syncId").asText(""),
|
||||
item.path("primaryMessageKey").asText(item.path("messageKey").asText("")),
|
||||
item.path("storedAtMs").asLong(0L),
|
||||
item.path("blobB64").asText("")
|
||||
));
|
||||
blobs));
|
||||
}
|
||||
}
|
||||
return new RemoteDmBatch(
|
||||
@@ -101,65 +181,47 @@ public final class RemoteDmSyncClient {
|
||||
}
|
||||
|
||||
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
|
||||
deleteMessage(null, serverAddressRaw, blobB64);
|
||||
}
|
||||
|
||||
public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
|
||||
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||
{
|
||||
"op":"DeleteMessage",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"blobB64":%s
|
||||
}
|
||||
"payload":{"blobB64":%s}
|
||||
}
|
||||
""".formatted("%s", blobJson));
|
||||
ensureOk("DeleteMessage", response);
|
||||
}
|
||||
|
||||
public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception {
|
||||
deleteConversation(null, serverAddressRaw, blobB64);
|
||||
}
|
||||
|
||||
public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
|
||||
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||
{
|
||||
"op":"DeleteConversation",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"blobB64":%s
|
||||
}
|
||||
"payload":{"blobB64":%s}
|
||||
}
|
||||
""".formatted("%s", blobJson));
|
||||
ensureOk("DeleteConversation", response);
|
||||
}
|
||||
|
||||
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||
String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID());
|
||||
String json = jsonTemplate.formatted(requestId);
|
||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
||||
if (wsUrl == null) {
|
||||
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
||||
}
|
||||
|
||||
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
||||
CountDownLatch openLatch = new CountDownLatch(1);
|
||||
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
||||
|
||||
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.buildAsync(URI.create(wsUrl), listener)
|
||||
.get(8, TimeUnit.SECONDS);
|
||||
|
||||
if (!openLatch.await(8, TimeUnit.SECONDS)) {
|
||||
tryAbort(webSocket);
|
||||
throw new TimeoutException("WS open timeout");
|
||||
}
|
||||
|
||||
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
|
||||
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
|
||||
tryAbort(webSocket);
|
||||
return MAPPER.readTree(responseJson);
|
||||
private JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||
return ServerConnectionPool.getInstance().request(
|
||||
targetServerLogin,
|
||||
serverAddressRaw,
|
||||
jsonTemplate,
|
||||
ServerConnectionPool.Priority.REALTIME);
|
||||
}
|
||||
|
||||
private String toOptionalJsonField(String fieldName, String value) throws Exception {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
if (value == null || value.isBlank()) return "";
|
||||
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
|
||||
}
|
||||
|
||||
@@ -171,76 +233,10 @@ public final class RemoteDmSyncClient {
|
||||
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
||||
}
|
||||
|
||||
private static void tryAbort(WebSocket webSocket) {
|
||||
try {
|
||||
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
webSocket.abort();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoteDmBatch(
|
||||
long nextStoredAtMs,
|
||||
String nextMessageKey,
|
||||
boolean hasMore,
|
||||
List<RemoteDmItem> items
|
||||
) {}
|
||||
|
||||
public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
|
||||
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
|
||||
public record RemoteDmItem(
|
||||
String messageKey,
|
||||
long storedAtMs,
|
||||
String blobB64
|
||||
String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64
|
||||
) {}
|
||||
|
||||
private static final class SyncWsListener implements WebSocket.Listener {
|
||||
private final CompletableFuture<String> responseFuture;
|
||||
private final CountDownLatch openLatch;
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
|
||||
private SyncWsListener(CompletableFuture<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package server.sync;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Логический последовательный сеанс поверх общего постоянного WSS-пула.
|
||||
* close() больше не закрывает физическое соединение с сервером.
|
||||
*/
|
||||
public final class RemoteSyncSession implements AutoCloseable {
|
||||
private final String serverLogin;
|
||||
private final String serverAddress;
|
||||
|
||||
/** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */
|
||||
public RemoteSyncSession(String serverAddressRaw) throws Exception {
|
||||
this(null, serverAddressRaw);
|
||||
}
|
||||
|
||||
public RemoteSyncSession(String serverLogin, String serverAddressRaw) {
|
||||
this.serverLogin = serverLogin;
|
||||
this.serverAddress = serverAddressRaw;
|
||||
}
|
||||
|
||||
public synchronized JsonNode send(String jsonTemplate) throws Exception {
|
||||
return ServerConnectionPool.getInstance().request(
|
||||
serverLogin,
|
||||
serverAddress,
|
||||
jsonTemplate,
|
||||
ServerConnectionPool.Priority.NORMAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Физическое соединение принадлежит ServerConnectionPool.
|
||||
}
|
||||
}
|
||||
+43
-86
@@ -4,27 +4,28 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class RemoteUserSettingsSyncClient {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
|
||||
public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
public void upsertUserSetting(
|
||||
String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
|
||||
) throws Exception {
|
||||
upsertUserSetting(null, serverAddressRaw, entry, syncDelivery);
|
||||
}
|
||||
|
||||
public void upsertUserSetting(
|
||||
String targetServerLogin, String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
|
||||
) throws Exception {
|
||||
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
|
||||
upsertUserSetting(session, entry, syncDelivery);
|
||||
}
|
||||
}
|
||||
|
||||
public void upsertUserSetting(RemoteSyncSession session, UserSettingEntry entry, boolean syncDelivery) throws Exception {
|
||||
JsonNode response = session.send("""
|
||||
{
|
||||
"op":"UpsertUserSetting",
|
||||
"requestId":%s,
|
||||
@@ -63,7 +64,33 @@ public final class RemoteUserSettingsSyncClient {
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
return userSettingsSyncBatch(null, serverAddressRaw, ownerLogin,
|
||||
afterTimeMs, afterSettingKey, limit, maxBytes);
|
||||
}
|
||||
|
||||
public RemoteUserSettingsBatch userSettingsSyncBatch(
|
||||
String targetServerLogin,
|
||||
String serverAddressRaw,
|
||||
String ownerLogin,
|
||||
long afterTimeMs,
|
||||
String afterSettingKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
|
||||
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public RemoteUserSettingsBatch userSettingsSyncBatch(
|
||||
RemoteSyncSession session,
|
||||
String ownerLogin,
|
||||
long afterTimeMs,
|
||||
String afterSettingKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
JsonNode response = session.send("""
|
||||
{
|
||||
"op":"UserSettingsSyncBatch",
|
||||
"requestId":%s,
|
||||
@@ -110,34 +137,6 @@ public final class RemoteUserSettingsSyncClient {
|
||||
);
|
||||
}
|
||||
|
||||
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||
String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID());
|
||||
String json = jsonTemplate.formatted(requestId);
|
||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
||||
if (wsUrl == null) {
|
||||
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
||||
}
|
||||
|
||||
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
||||
CountDownLatch openLatch = new CountDownLatch(1);
|
||||
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
||||
|
||||
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.buildAsync(URI.create(wsUrl), listener)
|
||||
.get(8, TimeUnit.SECONDS);
|
||||
|
||||
if (!openLatch.await(8, TimeUnit.SECONDS)) {
|
||||
tryAbort(webSocket);
|
||||
throw new TimeoutException("WS open timeout");
|
||||
}
|
||||
|
||||
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
|
||||
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
|
||||
tryAbort(webSocket);
|
||||
return MAPPER.readTree(responseJson);
|
||||
}
|
||||
|
||||
private void ensureOk(String op, JsonNode response) {
|
||||
int status = response.path("status").asInt(500);
|
||||
if (status >= 200 && status < 300) return;
|
||||
@@ -146,17 +145,6 @@ public final class RemoteUserSettingsSyncClient {
|
||||
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
||||
}
|
||||
|
||||
private static void tryAbort(WebSocket webSocket) {
|
||||
try {
|
||||
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
webSocket.abort();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoteUserSettingsBatch(
|
||||
long nextTimeMs,
|
||||
String nextSettingKey,
|
||||
@@ -175,35 +163,4 @@ public final class RemoteUserSettingsSyncClient {
|
||||
String signature
|
||||
) {}
|
||||
|
||||
private static final class SyncWsListener implements WebSocket.Listener {
|
||||
private final CompletableFuture<String> responseFuture;
|
||||
private final CountDownLatch openLatch;
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
|
||||
private SyncWsListener(CompletableFuture<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+763
@@ -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.
|
||||
*
|
||||
* <p>Пул меняет только транспорт: существующие JSON-операции, ACK, outbox и
|
||||
* расписания повторов остаются обязанностью вызывающих сервисов.</p>
|
||||
*/
|
||||
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<String, PeerConnection> 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<PeerMetricsSnapshot> snapshotMetrics() {
|
||||
List<PeerMetricsSnapshot> 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<String, String> 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<String, String> 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<String, String> 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<QueuedRequest> queue = new PriorityBlockingQueue<>();
|
||||
private final ConcurrentHashMap<String, CompletableFuture<JsonNode>> 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<Void> 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<Void> 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<Void> 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<Void> 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<JsonNode> 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<JsonNode> 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<JsonNode> 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<JsonNode> 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<String, CompletableFuture<JsonNode>> 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<JsonNode> 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<JsonNode> result
|
||||
) implements Comparable<QueuedRequest> {
|
||||
@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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,8 @@ public final class BlockchainResyncRecoveryOnStartup {
|
||||
blockchainName, partnerLogin, partnerAddress);
|
||||
|
||||
try {
|
||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads = REMOTE.listBlockchainHeads(partnerAddress);
|
||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
|
||||
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
|
||||
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
|
||||
.findFirst()
|
||||
|
||||
@@ -102,7 +102,7 @@ public final class PeriodicBlockchainSyncService {
|
||||
if (partnerLogin == null) return;
|
||||
|
||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> 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);
|
||||
|
||||
@@ -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<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
|
||||
Set<String> 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<UserAccessServerRouteEntry> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserAccessServerRouteEntry> 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.getServerLogin(), 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<UserSettingEntry> 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<String> 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) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
используют это соединение с разными приоритетами, не меняя свою бизнес-логику.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -34,13 +34,19 @@ 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.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=
|
||||
|
||||
@@ -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,16 +214,30 @@ public class IT_03_AddBlock_NoAuth {
|
||||
bch1, newsRootBlock, newsRootHash,
|
||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
// 3) FRIEND взаимно (на HEADER)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
|
||||
// 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);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# TODO: уведомления и full-resync после фиксации блоков в Arweave
|
||||
|
||||
## Контекст
|
||||
|
||||
`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup.
|
||||
|
||||
На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave.
|
||||
|
||||
## Что проверить после внедрения Arweave
|
||||
|
||||
Когда схема Arweave и правила восстановления цепочки стабилизируются:
|
||||
|
||||
- определить окончательный source of truth для accepted/finalized blocks;
|
||||
- проверить поведение `user_notifications_state` при rollback/full-resync;
|
||||
- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков;
|
||||
- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку;
|
||||
- добавить интеграционный тест на divergence + full-resync + notification projection.
|
||||
|
||||
## Важно
|
||||
|
||||
До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария.
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Build a source bundle ZIP while excluding credentials, private keys,
|
||||
# local state, generated artifacts and other likely secrets.
|
||||
#
|
||||
# Usage:
|
||||
# ./bundle.sh
|
||||
# ./bundle.sh path/to/output.zip
|
||||
#
|
||||
# Run from anywhere inside the project; the script resolves its own directory.
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
|
||||
case "$OUT" in
|
||||
/*) ;;
|
||||
*) OUT="$ROOT/$OUT" ;;
|
||||
esac
|
||||
|
||||
if ! command -v zip >/dev/null 2>&1; then
|
||||
echo "ERROR: 'zip' is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
LIST="$TMP/files.txt"
|
||||
SAFE_LIST="$TMP/safe-files.txt"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Paths / filenames that must never be bundled.
|
||||
is_denied_path() {
|
||||
local p="/$1"
|
||||
|
||||
case "$p" in
|
||||
*/.git/*|*/.git|\
|
||||
*/.gradle/*|*/.gradle|\
|
||||
*/.gradle-home/*|*/.gradle-home|\
|
||||
*/.idea/*|*/.idea|\
|
||||
*/.vscode/*|*/.vscode|\
|
||||
*/node_modules/*|*/node_modules|\
|
||||
*/target/*|*/target|\
|
||||
*/build/*|*/build|\
|
||||
*/out/*|*/out|\
|
||||
*/bin/*|*/bin|\
|
||||
*/logs/*|*/logs|\
|
||||
*/data/*|*/data|\
|
||||
*/test-ledger/*|*/test-ledger|\
|
||||
*/.anchor/*|*/.anchor|\
|
||||
*/.yarn/*|*/.yarn|\
|
||||
*/.vendor/*|*/.vendor|\
|
||||
*/.agents/*|*/.agents|\
|
||||
*/.codex/*|*/.codex|\
|
||||
*/.claude/*|*/.claude|\
|
||||
*/deploy/backup/archive/*|\
|
||||
*/scripts/*/runs/*|\
|
||||
*/scripts/*/keypairs/*|\
|
||||
*/keys/*|\
|
||||
*/.git-local-backup/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
local base="${p##*/}"
|
||||
local lower
|
||||
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$lower" in
|
||||
.env|.env.*|\
|
||||
.debug-token|\
|
||||
.npmrc|.pypirc|.netrc|\
|
||||
credentials|credentials.*|\
|
||||
secrets|secrets.*|\
|
||||
secret|secret.*|\
|
||||
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||
*keypair*.json|\
|
||||
service-account*.json|\
|
||||
firebase-adminsdk*.json|\
|
||||
google-services.json|\
|
||||
validator.log)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$lower" in
|
||||
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||
.ds_store)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||
else
|
||||
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||
fi
|
||||
|
||||
# Convert to project-relative paths and enforce hard deny rules.
|
||||
: > "$LIST"
|
||||
while IFS= read -r -d '' f; do
|
||||
if [[ "$f" = /* ]]; then
|
||||
rel="${f#"$ROOT"/}"
|
||||
else
|
||||
rel="$f"
|
||||
fi
|
||||
|
||||
[[ "$rel" == "$OUT" ]] && continue
|
||||
[[ -z "$rel" ]] && continue
|
||||
|
||||
if is_denied_path "$rel"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$LIST"
|
||||
done < "$TMP/files.z"
|
||||
|
||||
sort -u "$LIST" -o "$LIST"
|
||||
|
||||
# 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,}'
|
||||
|
||||
: > "$SAFE_LIST"
|
||||
found_secret=0
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -f "$ROOT/$rel" ]] || continue
|
||||
|
||||
# Files that contain examples/templates can legitimately mention secret keys
|
||||
# with placeholders. They are scanned too, but placeholder-looking values
|
||||
# are less likely to match the regex above.
|
||||
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||
echo "BLOCKED: possible secret in $rel" >&2
|
||||
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||
| head -n 3 >&2 || true
|
||||
found_secret=1
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||
done < "$LIST"
|
||||
|
||||
if (( found_secret != 0 )); then
|
||||
echo >&2
|
||||
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||
echo "ERROR: no files left to bundle." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
rm -f -- "$OUT"
|
||||
|
||||
(
|
||||
cd "$ROOT"
|
||||
zip -q -9 "$OUT" -@ < "$SAFE_LIST"
|
||||
)
|
||||
|
||||
echo "Created: $OUT"
|
||||
echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')"
|
||||
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||
@@ -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
|
||||
|
||||
@@ -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` — количество близких друзей пользователя.
|
||||
|
||||
### Успешный ответ: пользователя нет
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`:
|
||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||
|
||||
---
|
||||
|
||||
@@ -155,6 +158,7 @@
|
||||
"avaSha256": "0123...",
|
||||
"avaSize": 248193,
|
||||
"metaUpdatedAtMs": 1760000000000,
|
||||
"subscribersCount": 128,
|
||||
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||||
},
|
||||
"metaEvents": [
|
||||
|
||||
@@ -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 всех локальных блокчейнов |
|
||||
@@ -58,14 +59,16 @@
|
||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
||||
| `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` | постраничная загрузка истории личного диалога |
|
||||
@@ -75,7 +78,9 @@
|
||||
|
||||
## Важные замечания
|
||||
|
||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||
- `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`.
|
||||
|
||||
@@ -66,11 +66,44 @@
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"contacts": ["Bob", "Kate"]
|
||||
"dialogs": [
|
||||
{
|
||||
"peerLogin": "Bob",
|
||||
"relationFlag": "close_friend",
|
||||
"lastMessageBlobB64": "U0hpTkVfRE0B...",
|
||||
"lastMessageTimeMs": 1774700000123,
|
||||
"unreadCount": 2,
|
||||
"hasDialog": true
|
||||
},
|
||||
{
|
||||
"peerLogin": "Kate",
|
||||
"relationFlag": "contact",
|
||||
"lastMessageBlobB64": "",
|
||||
"lastMessageTimeMs": 0,
|
||||
"unreadCount": 0,
|
||||
"hasDialog": false
|
||||
},
|
||||
{
|
||||
"peerLogin": "Mira",
|
||||
"relationFlag": "none",
|
||||
"lastMessageBlobB64": "U0hpTkVfRE0B...",
|
||||
"lastMessageTimeMs": 1774700000555,
|
||||
"unreadCount": 1,
|
||||
"hasDialog": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `dialogs` это серверный inbox-проекционный список диалогов;
|
||||
- `relationFlag` возвращается как `close_friend`, `contact` или `none`;
|
||||
- если один и тот же человек есть и в `contact`, и в `close_friend`, в `dialogs` он приходит как `close_friend`.
|
||||
- `lastMessageBlobB64` содержит полный signed DM block последнего контентного сообщения в base64;
|
||||
- для чатов без сообщений поле `lastMessageBlobB64` пустое.
|
||||
|
||||
---
|
||||
|
||||
## 3. `GetUserConnectionsGraph`
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
||||
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
||||
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
|
||||
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
|
||||
|
||||
## 1. `UpsertPushToken`
|
||||
|
||||
@@ -105,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`
|
||||
@@ -145,7 +152,15 @@
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
||||
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. Пустой `sourceServerLogin` трактуется как клиентский вызов, непустой - как peer-вызов.
|
||||
|
||||
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
|
||||
|
||||
### Примечание
|
||||
|
||||
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
|
||||
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
|
||||
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
|
||||
|
||||
## 5. `DeleteMessage`
|
||||
|
||||
@@ -235,6 +250,7 @@
|
||||
"reencryptedAtMs": 0,
|
||||
"createdAtMs": 1774700001123,
|
||||
"readAtMs": 1774700001456,
|
||||
"deliveryState": "delivered",
|
||||
"blobB64": "BASE64_SIGNED_BLOCK"
|
||||
}
|
||||
]
|
||||
@@ -244,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
|
||||
{
|
||||
@@ -256,55 +311,62 @@
|
||||
"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` означает доставку хотя бы одному серверу получателя.
|
||||
|
||||
Ошибки:
|
||||
Периодический процесс использует один логический последовательный сеанс поверх
|
||||
постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает
|
||||
`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`
|
||||
|
||||
@@ -347,6 +409,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`.
|
||||
@@ -359,7 +440,7 @@
|
||||
|
||||
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
||||
- сервер не расшифровывает DM и не использует ciphertext как preview текста
|
||||
- сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview
|
||||
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
|
||||
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
|
||||
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
- `value_num = number of messages already seen in channel`;
|
||||
- `value_text = ''`.
|
||||
|
||||
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`.
|
||||
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
|
||||
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
|
||||
|
||||
## 2. Структура записи
|
||||
|
||||
@@ -76,15 +77,20 @@
|
||||
Внутренний служебный запрос.
|
||||
|
||||
- помечает все настройки пользователя или все настройки сразу как `synced=false`;
|
||||
- одновременно сбрасывает DM-outbox того же пользователя (или всех пользователей), чтобы операция замены/добавления access-сервера не оставила историю DM несинхронизированной;
|
||||
- ответ дополнительно содержит `dmUpdated` — количество сброшенных DM-событий;
|
||||
- нужен после добавления нового sync-сервера или при потере локальной БД.
|
||||
|
||||
## 5. Синхронизация
|
||||
|
||||
Синхронизация настроек работает отдельно от DM.
|
||||
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс
|
||||
использует один логический последовательный сеанс поверх постоянного WSS-пула:
|
||||
сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Завершение
|
||||
логического сеанса не закрывает физический сокет.
|
||||
|
||||
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
|
||||
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
|
||||
- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору;
|
||||
- периодический sync раз в 6 часов проверяет несинхронизированные настройки, догружает их по курсору и затем забирает несинхронизированные DM;
|
||||
- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля.
|
||||
|
||||
## 6. Текущий UI-кейс
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# API для разработчиков: уведомления
|
||||
|
||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
||||
|
||||
Текущая операция:
|
||||
|
||||
- `GetNotifications`
|
||||
|
||||
## 1. `GetNotifications`
|
||||
|
||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
||||
|
||||
Возвращаются две отдельные ленты:
|
||||
|
||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
||||
- `events` — события добавления в `close_friend`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"replies": [
|
||||
{
|
||||
"kind": "reply",
|
||||
"createdAtMs": 1755673200000,
|
||||
"sourceLogin": "Bob",
|
||||
"sourceBlockchainName": "bob-001",
|
||||
"sourceBlockNumber": 42,
|
||||
"sourceBlockHash": "ab12...",
|
||||
"sourceMsgSubType": 20,
|
||||
"sourceText": "Спасибо!",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 18,
|
||||
"targetBlockHash": "cd34..."
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"kind": "close_friend",
|
||||
"createdAtMs": 1755673300000,
|
||||
"sourceLogin": "Kate",
|
||||
"sourceBlockchainName": "kate-001",
|
||||
"sourceBlockNumber": 7,
|
||||
"sourceBlockHash": "ef56...",
|
||||
"sourceMsgSubType": 10,
|
||||
"sourceText": "close_friend",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 0,
|
||||
"targetBlockHash": "0000..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `replies` заполняется только для `TEXT_REPLY`.
|
||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
||||
- Другие типы связей в эту ленту не попадают.
|
||||
@@ -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;
|
||||
- размеры очередей по приоритетам;
|
||||
- успешные, ошибочные и просроченные запросы;
|
||||
- последнюю ошибку.
|
||||
|
||||
Агрегированное состояние периодически записывается в серверный лог. Отдельная
|
||||
публичная операция метрик на этом этапе не добавляется.
|
||||
@@ -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`.
|
||||
- Канонический текстовый формат служебных тегов сокращён:
|
||||
|
||||
@@ -42,4 +42,6 @@
|
||||
## Обязательное сопровождение
|
||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
||||
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.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` с датой/временем и хэшем коммита-основания.
|
||||
|
||||
@@ -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,25 +183,32 @@ Full resync запускается только тогда, когда:
|
||||
|
||||
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
|
||||
|
||||
## 5. Целевой протокол следующего этапа
|
||||
## 5. Реализованный постоянный server-to-server транспорт
|
||||
|
||||
Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
|
||||
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
|
||||
|
||||
### 5.1 Межсерверное соединение
|
||||
|
||||
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
|
||||
- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
|
||||
- Адрес партнёра определяется по `server_address` из его Solana PDA.
|
||||
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
|
||||
- При разрыве — переподключение с экспоненциальным backoff.
|
||||
- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
|
||||
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
|
||||
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
|
||||
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
|
||||
- Один физический канал переиспользуют DM, настройки и blockchain.
|
||||
|
||||
### 5.2 Доставка новых данных (push)
|
||||
|
||||
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
|
||||
- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам.
|
||||
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
|
||||
- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
|
||||
|
||||
### 5.3 Начальная синхронизация (backfill)
|
||||
|
||||
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
|
||||
последний глобальный номер блока, последний известный DM-ключ.
|
||||
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
|
||||
- Сервер с более полной историей досылает недостающее партнёру.
|
||||
- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`.
|
||||
|
||||
### 5.4 Разрешение конфликтов
|
||||
|
||||
@@ -212,19 +221,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. Статус реализации
|
||||
|
||||
@@ -239,16 +250,21 @@ Full resync запускается только тогда, когда:
|
||||
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
|
||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
|
||||
| Push новых DM партнёрам | Нужна реализация |
|
||||
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
||||
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
|
||||
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
|
||||
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
||||
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
|
||||
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
|
||||
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
|
||||
| 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 соединения.
|
||||
Текущая версия сервера использует постоянный WSS-пул для существующих
|
||||
server-to-server JSON-операций. Отдельной будущей задачей остаётся
|
||||
криптографическая авторизация server-to-server вызовов.
|
||||
|
||||
Следующие отдельные шаги после текущего этапа:
|
||||
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
||||
|
||||
@@ -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` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
|
||||
|
||||
Исторический устаревший документ сохранён отдельно:
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Доставка и синхронизация личных сообщений
|
||||
|
||||
## 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, локально сразу считается синхронизированным, чтобы не образовалась петля.
|
||||
|
||||
`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 и не изменяет данные.
|
||||
@@ -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,33 @@
|
||||
- `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. Серверный слой диалогов
|
||||
|
||||
Помимо хранения самих 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. Правила валидации и применения
|
||||
|
||||
@@ -427,8 +462,12 @@ Request:
|
||||
Правила:
|
||||
|
||||
- клиенту достаточно отправить пару на один любой доступный сервер;
|
||||
- успешный `status=200` означает локальное сохранение;
|
||||
- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя;
|
||||
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
|
||||
|
||||
Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`.
|
||||
|
||||
### 10.2. `ReceiveIncomingMessage`
|
||||
|
||||
Назначение:
|
||||
@@ -450,7 +489,7 @@ Request:
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
|
||||
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
|
||||
|
||||
### 10.3. `DeleteMessage`
|
||||
|
||||
@@ -539,6 +578,8 @@ UI-следствие для клиента:
|
||||
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
|
||||
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
|
||||
|
||||
Для исходящих элементов сервер также возвращает единственное поле `deliveryState`.
|
||||
|
||||
## 11. Межсерверная доставка
|
||||
|
||||
### 11.1. Клиентская сторона
|
||||
@@ -549,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`, сервер:
|
||||
|
||||
@@ -599,17 +616,20 @@ UI-следствие для клиента:
|
||||
- не отправляет realtime/push-уведомления клиентам;
|
||||
- не запускает повторный fan-out, чтобы не создавать циклы.
|
||||
|
||||
Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов.
|
||||
Синхронизация настроек и DM выполняется одним периодическим процессом и через
|
||||
один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие
|
||||
этого логического сеанса не закрывает физическое соединение с 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. Хранение в БД
|
||||
|
||||
@@ -634,11 +654,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
|
||||
|
||||
@@ -651,7 +673,7 @@ UI-следствие для клиента:
|
||||
- межсерверная маршрутизация DM должна идти через `access_servers`;
|
||||
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
|
||||
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
|
||||
- логика должна быть безопасна для нескольких серверов у каждой стороны.
|
||||
- логика должна быть безопасна для одного или двух серверов у каждой стороны.
|
||||
|
||||
## 14. Что в v1 пока не входит
|
||||
|
||||
@@ -659,4 +681,4 @@ UI-следствие для клиента:
|
||||
- хранение отдельного `keyId` шифрования в DM;
|
||||
- ротация `clientKey`;
|
||||
- финальная конкретная UI-реализация массовой перешифровки;
|
||||
- межсерверная авторизация `DmSyncBatch`.
|
||||
- межсерверная авторизация DM-синхронизации и доставки.
|
||||
|
||||
@@ -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. Общие правила
|
||||
|
||||
@@ -252,6 +255,15 @@ ReadReceiptBody_v1_0
|
||||
|
||||
Различается только `messageType` и формат `body`.
|
||||
|
||||
### Серверное примечание
|
||||
|
||||
Внешний байтовый формат `type=3/4` не меняется, но сервер использует такие контейнеры как вход для обновления `dm_dialog_state`:
|
||||
|
||||
- `read receipt` обновляет серверный watermark диалога;
|
||||
- `unreadCount` пересчитывается на сервере, а не только на клиенте;
|
||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||
|
||||
## 9. Контент типов `5/6`
|
||||
|
||||
Типы:
|
||||
@@ -328,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`.
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
+310
-5
@@ -12,13 +12,13 @@
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260819190000';
|
||||
window.__SHINE_BUILD_HASH__ = '20260822140000';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
@@ -52,8 +52,284 @@ window.__SHINE_BUILD_HASH__ = '20260819190000';
|
||||
</script>
|
||||
<script>
|
||||
(function attachBootErrorOverlay() {
|
||||
const show = (title, text) => {
|
||||
const stateKey = '__SHINE_BOOT_ERROR_STATE__';
|
||||
const menuId = 'boot-error-action-sheet';
|
||||
const escapeText = (value) => String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
const safeString = (value, maxLen = 1000) => {
|
||||
const text = String(value == null ? '' : value).trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
|
||||
};
|
||||
const setState = (next) => {
|
||||
try {
|
||||
window[stateKey] = next;
|
||||
} catch {}
|
||||
};
|
||||
const getState = () => {
|
||||
try {
|
||||
return window[stateKey] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const getKnownClientError = () => {
|
||||
try {
|
||||
return typeof window.__SHINE_GET_LAST_CLIENT_ERROR__ === 'function'
|
||||
? window.__SHINE_GET_LAST_CLIENT_ERROR__()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const showFeedback = (message, kind = 'success') => {
|
||||
try {
|
||||
if (typeof window.__SHINE_SHOW_TOAST__ === 'function') {
|
||||
window.__SHINE_SHOW_TOAST__(message, kind);
|
||||
} else {
|
||||
console.info(message);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const buildReport = () => {
|
||||
const state = getState() || {};
|
||||
const known = getKnownClientError();
|
||||
const viewport = {
|
||||
width: Math.round(window.innerWidth || 0),
|
||||
height: Math.round(window.innerHeight || 0),
|
||||
dpr: Number(window.devicePixelRatio || 1),
|
||||
visualWidth: Math.round(window.visualViewport?.width || 0),
|
||||
visualHeight: Math.round(window.visualViewport?.height || 0),
|
||||
visualScale: Number(window.visualViewport?.scale || 1),
|
||||
};
|
||||
const screenInfo = window.screen ? {
|
||||
width: Math.round(window.screen.width || 0),
|
||||
height: Math.round(window.screen.height || 0),
|
||||
availWidth: Math.round(window.screen.availWidth || 0),
|
||||
availHeight: Math.round(window.screen.availHeight || 0),
|
||||
pixelDepth: Number(window.screen.pixelDepth || 0),
|
||||
} : null;
|
||||
return {
|
||||
kind: safeString(state.kind || 'boot_error', 64),
|
||||
title: safeString(state.title || 'BOOT ERROR', 128),
|
||||
message: safeString(state.message || '', 500),
|
||||
stack: safeString(state.stack || '', 8000),
|
||||
sourceUrl: safeString(state.sourceUrl || '', 240),
|
||||
lineNumber: Number.isFinite(state.lineNumber) ? state.lineNumber : null,
|
||||
columnNumber: Number.isFinite(state.columnNumber) ? state.columnNumber : null,
|
||||
reasonType: safeString(state.reasonType || '', 64),
|
||||
route: safeString(window.location.hash || window.location.pathname || '', 200),
|
||||
href: safeString(window.location.href || '', 240),
|
||||
pageTitle: safeString(document.title || '', 200),
|
||||
pageVisibility: safeString(document.visibilityState || '', 32),
|
||||
userAgent: safeString(navigator.userAgent || '', 240),
|
||||
locale: safeString(navigator.language || '', 32),
|
||||
clientTs: Number.isFinite(state.clientTs) ? state.clientTs : Date.now(),
|
||||
viewport,
|
||||
screenInfo,
|
||||
lastKnownClientError: known || null,
|
||||
contextJson: safeString(JSON.stringify({
|
||||
bootState: state,
|
||||
currentRoute: window.location.hash || window.location.pathname || '',
|
||||
hasClientErrorSender: typeof window.__SHINE_SEND_CLIENT_ERROR__ === 'function',
|
||||
}), 2000),
|
||||
};
|
||||
};
|
||||
const buildSendPayload = (report) => {
|
||||
const payload = {
|
||||
kind: safeString(report?.kind || 'boot_error', 64),
|
||||
message: safeString(report?.message || report?.title || 'Неизвестная ошибка', 500),
|
||||
stack: safeString(report?.stack || '', 8000),
|
||||
sourceUrl: safeString(report?.sourceUrl || '', 240),
|
||||
lineNumber: Number.isFinite(report?.lineNumber) ? report.lineNumber : null,
|
||||
columnNumber: Number.isFinite(report?.columnNumber) ? report.columnNumber : null,
|
||||
route: safeString(report?.route || '', 200),
|
||||
href: safeString(report?.href || '', 240),
|
||||
userAgent: safeString(report?.userAgent || '', 240),
|
||||
clientTs: Number.isFinite(report?.clientTs) ? report.clientTs : Date.now(),
|
||||
requestOp: '',
|
||||
requestIdRef: '',
|
||||
contextJson: safeString(JSON.stringify({
|
||||
...report,
|
||||
contextJson: undefined,
|
||||
}), 2000),
|
||||
};
|
||||
return payload;
|
||||
};
|
||||
const shareErrorText = async (report) => {
|
||||
const text = formatReportText(report);
|
||||
if (!text) throw new Error('Текст ошибки пуст');
|
||||
if (!navigator.share) {
|
||||
throw new Error('Отправка через системное меню недоступна в этом браузере');
|
||||
}
|
||||
try {
|
||||
await navigator.share({
|
||||
title: report?.title || 'Описание ошибки',
|
||||
text,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return false;
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const formatReportText = (report) => {
|
||||
const lines = [
|
||||
`Ошибка: ${report.title || report.kind || 'unknown'}`,
|
||||
`Описание: ${report.message || '—'}`,
|
||||
`Окно: ${report.pageTitle || '—'}`,
|
||||
`Маршрут: ${report.route || '—'}`,
|
||||
`URL: ${report.href || '—'}`,
|
||||
`Видимость: ${report.pageVisibility || '—'}`,
|
||||
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
|
||||
`UA: ${report.userAgent || '—'}`,
|
||||
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
|
||||
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
|
||||
`Источник: ${report.sourceUrl || '—'}`,
|
||||
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
|
||||
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
|
||||
`Тип причины: ${report.reasonType || '—'}`,
|
||||
];
|
||||
if (report.stack) {
|
||||
lines.push('Stack:', report.stack);
|
||||
}
|
||||
if (report.lastKnownClientError) {
|
||||
lines.push('Последняя известная ошибка:', JSON.stringify(report.lastKnownClientError, null, 2));
|
||||
}
|
||||
if (report.contextJson) {
|
||||
lines.push('Контекст:', report.contextJson);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
const copyText = async (text) => {
|
||||
const value = String(text || '');
|
||||
if (!value) return false;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.setAttribute('readonly', 'readonly');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
ta.style.pointerEvents = 'none';
|
||||
document.body.append(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
};
|
||||
const removeMenu = () => {
|
||||
try {
|
||||
document.getElementById(menuId)?.remove();
|
||||
} catch {}
|
||||
};
|
||||
const openMenu = () => {
|
||||
try {
|
||||
removeMenu();
|
||||
const report = buildReport();
|
||||
const root = document.getElementById('modal-root') || document.body;
|
||||
const shell = document.createElement('div');
|
||||
shell.id = menuId;
|
||||
shell.className = 'modal-shell boot-error-menu-shell';
|
||||
shell.innerHTML = `
|
||||
<div class="modal-backdrop" data-action="close"></div>
|
||||
<div class="modal-dialog boot-error-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="boot-error-menu-title" tabindex="-1">
|
||||
<div class="modal-card stack boot-error-menu-card">
|
||||
<strong class="modal-title" id="boot-error-menu-title">Описание ошибки</strong>
|
||||
<p class="meta-muted boot-error-menu-message">${escapeText(report.message || report.title || 'Ошибка')}</p>
|
||||
<button type="button" class="secondary-btn" data-action="copy">Скопировать текст ошибки</button>
|
||||
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</button>
|
||||
<button type="button" class="secondary-btn" data-action="send"${typeof window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ === 'function' ? '' : ' disabled'}>Отправить в лог на сервере</button>
|
||||
<button type="button" class="ghost-btn" data-action="close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.append(shell);
|
||||
|
||||
const dialog = shell.querySelector('.modal-dialog');
|
||||
const close = () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
shell.remove();
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
shell.addEventListener('click', (event) => {
|
||||
if (event.target === shell || event.target?.dataset?.action === 'close') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="copy"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await copyText(formatReportText(report));
|
||||
showFeedback('Описание ошибки скопировано');
|
||||
} catch {
|
||||
showFeedback('Не удалось скопировать описание ошибки', 'error');
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="share"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const shared = await shareErrorText(report);
|
||||
if (shared === false) return close();
|
||||
showFeedback('Текст ошибки открыт для отправки');
|
||||
} catch (error) {
|
||||
showFeedback(error?.message || 'Не удалось открыть системное меню отправки', 'error');
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="send"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const sender = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
|
||||
if (typeof sender !== 'function') {
|
||||
throw new Error('Отправка в лог на сервере недоступна');
|
||||
}
|
||||
const ok = await sender(buildSendPayload(report));
|
||||
if (!ok) {
|
||||
throw new Error('Не удалось отправить ошибку в лог на сервере');
|
||||
}
|
||||
showFeedback('Ошибка отправлена в лог на сервере');
|
||||
} catch (error) {
|
||||
showFeedback(error?.message || 'Не удалось отправить ошибку в лог на сервере', 'error');
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="close"]')?.addEventListener('click', close);
|
||||
dialog?.focus?.();
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
} catch (error) {
|
||||
console.warn('boot error menu failed', error);
|
||||
}
|
||||
};
|
||||
const show = (title, text, extra = {}) => {
|
||||
try {
|
||||
const reportState = {
|
||||
kind: safeString(extra.kind || title || 'boot_error', 64),
|
||||
title: safeString(title || 'BOOT ERROR', 128),
|
||||
message: safeString(extra.message || text || '', 500),
|
||||
stack: safeString(extra.stack || '', 8000),
|
||||
sourceUrl: safeString(extra.sourceUrl || extra.filename || '', 240),
|
||||
lineNumber: Number.isFinite(extra.lineNumber) ? extra.lineNumber : (Number.isFinite(extra.lineno) ? extra.lineno : null),
|
||||
columnNumber: Number.isFinite(extra.columnNumber) ? extra.columnNumber : (Number.isFinite(extra.colno) ? extra.colno : null),
|
||||
reasonType: safeString(extra.reasonType || '', 64),
|
||||
clientTs: Number.isFinite(extra.clientTs) ? extra.clientTs : Date.now(),
|
||||
};
|
||||
setState(reportState);
|
||||
|
||||
let el = document.getElementById('boot-error-overlay');
|
||||
if (!el) {
|
||||
el = document.createElement('pre');
|
||||
@@ -72,17 +348,46 @@ window.__SHINE_BUILD_HASH__ = '20260819190000';
|
||||
el.style.lineHeight = '1.4';
|
||||
el.style.zIndex = '999999';
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
el.style.cursor = 'pointer';
|
||||
el.style.userSelect = 'none';
|
||||
el.style.webkitUserSelect = 'none';
|
||||
el.style.touchAction = 'manipulation';
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('aria-haspopup', 'dialog');
|
||||
document.body.appendChild(el);
|
||||
el.addEventListener('click', openMenu);
|
||||
el.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
el.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`;
|
||||
el.setAttribute('aria-label', `${title}. Нажмите, чтобы открыть меню действий.`);
|
||||
el.title = 'Нажмите, чтобы открыть меню действий';
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener('error', (e) => {
|
||||
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`);
|
||||
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
|
||||
kind: 'window_error',
|
||||
message: e?.message || '',
|
||||
stack: e?.error?.stack || '',
|
||||
sourceUrl: e?.filename || '',
|
||||
lineNumber: e?.lineno,
|
||||
columnNumber: e?.colno,
|
||||
reasonType: e?.error?.constructor?.name || e?.constructor?.name || 'ErrorEvent',
|
||||
});
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e?.reason;
|
||||
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'));
|
||||
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'), {
|
||||
kind: 'unhandled_rejection',
|
||||
message: reason?.message || String(reason || 'Unhandled promise rejection'),
|
||||
stack: reason?.stack || '',
|
||||
reasonType: reason?.constructor?.name || typeof reason,
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
+54
-7
@@ -5,7 +5,13 @@ import {
|
||||
syncTrackedRouteHistory,
|
||||
} from './router.js';
|
||||
import { renderToolbar } from './components/toolbar.js';
|
||||
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||
import {
|
||||
captureClientError,
|
||||
getLastClientErrorPayload,
|
||||
setClientErrorSentNotifier,
|
||||
setClientErrorTransport,
|
||||
} from './services/client-error-reporter.js';
|
||||
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
|
||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||
@@ -37,6 +43,7 @@ import {
|
||||
deleteConversationMessagesBefore,
|
||||
deleteSignedMessageByBaseKey,
|
||||
markIncomingReadByBaseKey,
|
||||
markOutgoingDeliveryState,
|
||||
markOutgoingReadByBaseKey,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
@@ -80,17 +87,18 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202608191738';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelAboutView from './pages/channel-about-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js';
|
||||
import * as notificationsView from './pages/notifications-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -148,6 +156,7 @@ const routes = {
|
||||
user: userProfileView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'channel-about-view': channelAboutView,
|
||||
'channel-thread-view': channelThreadView,
|
||||
'add-channel-view': addChannelView,
|
||||
'add-personal-public-chat-view': addPersonalPublicChatView,
|
||||
@@ -190,12 +199,22 @@ let initialConnectionCompleted = false;
|
||||
let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
'messages-list',
|
||||
'chat-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
'channel-thread-view',
|
||||
'notifications-view',
|
||||
]);
|
||||
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
'network-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
'channel-about-view',
|
||||
'channel-thread-view',
|
||||
'user',
|
||||
'contact-search-view',
|
||||
@@ -208,6 +227,10 @@ setClientErrorSentNotifier((payload) => {
|
||||
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
|
||||
showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`);
|
||||
});
|
||||
window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ = (payload) => authService.reportClientError(payload);
|
||||
window.__SHINE_SEND_CLIENT_ERROR__ = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
|
||||
window.__SHINE_GET_LAST_CLIENT_ERROR__ = () => getLastClientErrorPayload();
|
||||
window.__SHINE_SHOW_TOAST__ = (message, kind = 'success') => showToast(message, { kind });
|
||||
initPwaInstallPromptHandling();
|
||||
initCallUiOverlay();
|
||||
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
||||
@@ -1128,6 +1151,14 @@ function renderPageFailureFallback(pageId, error) {
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
function attachPageScrollToBottom(pageId, screen) {
|
||||
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
|
||||
|
||||
return attachScrollToBottomButton({
|
||||
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
|
||||
});
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
@@ -1164,7 +1195,12 @@ function renderApp() {
|
||||
}
|
||||
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
|
||||
currentCleanup = () => {
|
||||
pageCleanup?.();
|
||||
scrollToBottomControl?.cleanup();
|
||||
};
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
@@ -1358,6 +1394,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();
|
||||
|
||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
export function renderAvatar({
|
||||
initials = '?',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = ['avatar', 'avatar-image'];
|
||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||
const sizeClass = pickSizeClass(size);
|
||||
if (sizeClass) classes.push(sizeClass);
|
||||
if (sizeClass) classes.add(sizeClass);
|
||||
const extraClass = String(className || '').trim();
|
||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||
wrap.className = classes.join(' ');
|
||||
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||
if (glow) classes.add('avatar-glow');
|
||||
wrap.className = Array.from(classes).join(' ');
|
||||
if (title) wrap.title = String(title);
|
||||
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'avatar-fallback';
|
||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||
wrap.append(fallback);
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
img.className = 'avatar-photo';
|
||||
img.alt = String(alt || 'Аватар');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
wrap.append(img);
|
||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
return renderAvatar({
|
||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||
avatar,
|
||||
size,
|
||||
className,
|
||||
title,
|
||||
alt: 'Аватар',
|
||||
glow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header';
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = leftAction.label;
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, h1, right);
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function createOverflowDots({ className = '' } = {}) {
|
||||
const dots = document.createElement('span');
|
||||
const extra = String(className || '').trim();
|
||||
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
|
||||
dots.setAttribute('aria-hidden', 'true');
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
dots.append(document.createElement('i'));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function attachScrollToBottomButton({
|
||||
scrollContainer,
|
||||
mountTarget = document.querySelector('.app-shell'),
|
||||
thresholdPx = 160,
|
||||
title = 'Вниз',
|
||||
} = {}) {
|
||||
const target = resolveElement(mountTarget);
|
||||
if (!(target instanceof Element)) {
|
||||
return {
|
||||
button: null,
|
||||
refresh() {},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'scroll-to-bottom-btn';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||
button.tabIndex = -1;
|
||||
button.append(buildArrowIcon());
|
||||
target.append(button);
|
||||
|
||||
let disposed = false;
|
||||
let boundContainer = null;
|
||||
let resizeObserver = null;
|
||||
let mutationObserver = null;
|
||||
let refreshFrame = 0;
|
||||
|
||||
const hide = () => {
|
||||
button.classList.remove('is-visible');
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
button.tabIndex = -1;
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (disposed || refreshFrame) return;
|
||||
refreshFrame = window.requestAnimationFrame(() => {
|
||||
refreshFrame = 0;
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const unbindContainer = () => {
|
||||
boundContainer?.removeEventListener('scroll', scheduleRefresh);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
mutationObserver = null;
|
||||
boundContainer = null;
|
||||
};
|
||||
|
||||
const bindContainer = () => {
|
||||
const nextContainer = resolveElement(scrollContainer);
|
||||
if (!(nextContainer instanceof Element)) {
|
||||
if (boundContainer) unbindContainer();
|
||||
return null;
|
||||
}
|
||||
if (nextContainer === boundContainer) return boundContainer;
|
||||
|
||||
unbindContainer();
|
||||
boundContainer = nextContainer;
|
||||
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(scheduleRefresh);
|
||||
resizeObserver.observe(boundContainer);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
mutationObserver = new MutationObserver(scheduleRefresh);
|
||||
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return boundContainer;
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
if (disposed) return;
|
||||
const container = bindContainer();
|
||||
if (!container) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollHeight = Number(container.scrollHeight || 0);
|
||||
const clientHeight = Number(container.clientHeight || 0);
|
||||
const scrollTop = Number(container.scrollTop || 0);
|
||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
||||
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
|
||||
|
||||
button.classList.toggle('is-visible', shouldShow);
|
||||
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||
button.tabIndex = shouldShow ? 0 : -1;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = bindContainer();
|
||||
if (!container) return;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
scheduleRefresh();
|
||||
};
|
||||
|
||||
button.addEventListener('click', scrollToBottom);
|
||||
window.addEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.addEventListener('resize', scheduleRefresh);
|
||||
window.requestAnimationFrame(refresh);
|
||||
|
||||
return {
|
||||
button,
|
||||
refresh: scheduleRefresh,
|
||||
cleanup() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (refreshFrame) {
|
||||
window.cancelAnimationFrame(refreshFrame);
|
||||
refreshFrame = 0;
|
||||
}
|
||||
unbindContainer();
|
||||
window.removeEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
||||
button.removeEventListener('click', scrollToBottom);
|
||||
button.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user