SHA256
Compare commits
2
Commits
7d929e626e
...
01321f5200
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
01321f5200 | ||
|
|
3a5851939e |
@@ -30,6 +30,8 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_11 = 11;
|
public static final int SCHEMA_VERSION_11 = 11;
|
||||||
public static final int SCHEMA_VERSION_12 = 12;
|
public static final int SCHEMA_VERSION_12 = 12;
|
||||||
public static final int SCHEMA_VERSION_13 = 13;
|
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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -43,6 +45,8 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
|
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.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_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() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -154,6 +158,14 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_13;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+96
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||||
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||||
|
|
||||||
|
rebuildStatsState(c);
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
|
|
||||||
return new CleanupResult(
|
return new CleanupResult(
|
||||||
@@ -366,6 +368,100 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
""", blockchainName);
|
""", 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 {
|
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, value);
|
ps.setString(1, value);
|
||||||
|
|||||||
+29
@@ -75,6 +75,35 @@ public final class UserAccessServersCurrentDAO {
|
|||||||
return result;
|
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 {
|
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||||
entry.setUserLogin(rs.getString("user_login"));
|
entry.setUserLogin(rs.getString("user_login"));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -291,6 +291,12 @@ AFTER TRUNCATE ON solana_user_pda_current
|
|||||||
FOR EACH STATEMENT
|
FOR EACH STATEMENT
|
||||||
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
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 (
|
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
tx_signature TEXT NOT NULL,
|
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
|
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||||
ON channel_names_state (owner_login, owner_bch_name);
|
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 (
|
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||||
owner_login TEXT NOT NULL,
|
owner_login TEXT NOT NULL,
|
||||||
owner_bch_name TEXT NOT NULL,
|
owner_bch_name TEXT NOT NULL,
|
||||||
@@ -921,6 +953,132 @@ BEGIN
|
|||||||
END;
|
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()
|
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@@ -999,6 +1157,8 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
resolved_login TEXT;
|
resolved_login TEXT;
|
||||||
positive_rel_type INTEGER;
|
positive_rel_type INTEGER;
|
||||||
|
existed_before BOOLEAN;
|
||||||
|
target_channel_type INTEGER;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.msg_type <> 3 THEN
|
IF NEW.msg_type <> 3 THEN
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
@@ -1011,10 +1171,159 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END IF;
|
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
|
DELETE FROM connections_state
|
||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
AND rel_type = NEW.msg_sub_type
|
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 (
|
INSERT INTO connections_state (
|
||||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
@@ -1048,10 +1357,161 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END IF;
|
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
|
DELETE FROM connections_state
|
||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
AND rel_type = positive_rel_type
|
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;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
@@ -1231,4 +1691,10 @@ AFTER INSERT ON blocks
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
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;
|
COMMIT;
|
||||||
|
|||||||
+26
@@ -4,6 +4,8 @@ import org.eclipse.jetty.websocket.api.Session;
|
|||||||
import shine.db.entities.CurrentUserEntry;
|
import shine.db.entities.CurrentUserEntry;
|
||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConnectionContext — контекст состояния одного WebSocket-соединения.
|
* ConnectionContext — контекст состояния одного WebSocket-соединения.
|
||||||
* Живёт ровно столько же, сколько живёт подключение.
|
* Живёт ровно столько же, сколько живёт подключение.
|
||||||
@@ -77,6 +79,12 @@ public class ConnectionContext {
|
|||||||
*/
|
*/
|
||||||
private Session wsSession;
|
private Session wsSession;
|
||||||
|
|
||||||
|
/** Временная server-to-server роль, заявленная через ServerHello. */
|
||||||
|
private boolean serverConnection;
|
||||||
|
private String remoteServerLogin;
|
||||||
|
private int remoteServerProtocolVersion;
|
||||||
|
private List<String> remoteServerCapabilities = List.of();
|
||||||
|
|
||||||
// --- WebSocket Session ---
|
// --- WebSocket Session ---
|
||||||
|
|
||||||
public Session getWsSession() {
|
public Session getWsSession() {
|
||||||
@@ -87,6 +95,20 @@ public class ConnectionContext {
|
|||||||
this.wsSession = wsSession;
|
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 ---
|
// --- SolanaUser / ActiveSession ---
|
||||||
|
|
||||||
public CurrentUserEntry getCurrentUser() {
|
public CurrentUserEntry getCurrentUser() {
|
||||||
@@ -188,6 +210,10 @@ public class ConnectionContext {
|
|||||||
|
|
||||||
authenticationStatus = AUTH_STATUS_NONE;
|
authenticationStatus = AUTH_STATUS_NONE;
|
||||||
wsSession = null;
|
wsSession = null;
|
||||||
|
serverConnection = false;
|
||||||
|
remoteServerLogin = null;
|
||||||
|
remoteServerProtocolVersion = 0;
|
||||||
|
remoteServerCapabilities = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+4
@@ -133,6 +133,7 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_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_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_CallDeliveryReport_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_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;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
|
||||||
@@ -141,6 +142,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_
|
|||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_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_Ping_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -232,6 +234,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("SendSignal", new Net_SendSignal_Handler()),
|
Map.entry("SendSignal", new Net_SendSignal_Handler()),
|
||||||
|
|
||||||
// --- system ---
|
// --- system ---
|
||||||
|
Map.entry("ServerHello", new Net_ServerHello_Handler()),
|
||||||
Map.entry("Ping", new Net_Ping_Handler()),
|
Map.entry("Ping", new Net_Ping_Handler()),
|
||||||
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
||||||
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
|
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
|
||||||
@@ -323,6 +326,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("SendSignal", Net_SendSignal_Request.class),
|
Map.entry("SendSignal", Net_SendSignal_Request.class),
|
||||||
|
|
||||||
// --- system ---
|
// --- system ---
|
||||||
|
Map.entry("ServerHello", Net_ServerHello_Request.class),
|
||||||
Map.entry("Ping", Net_Ping_Request.class),
|
Map.entry("Ping", Net_Ping_Request.class),
|
||||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
||||||
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
|
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
|
||||||
|
|||||||
+27
@@ -16,6 +16,8 @@ import utils.blockchain.BlockchainNameUtil;
|
|||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||||
channel.setChannelTypeCode(meta.channelTypeCode);
|
channel.setChannelTypeCode(meta.channelTypeCode);
|
||||||
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
||||||
|
channel.setSubscribersCount(loadSubscribersCount(c, ownerBch, lineCode, meta.channelTypeCode));
|
||||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
rootRef.setBlockNumber(lineCode);
|
rootRef.setBlockNumber(lineCode);
|
||||||
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
|
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", "Внутренняя ошибка сервера");
|
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 Long metaUpdatedAtMs;
|
||||||
private Integer channelTypeCode;
|
private Integer channelTypeCode;
|
||||||
private Integer channelTypeVersion;
|
private Integer channelTypeVersion;
|
||||||
|
private Integer subscribersCount;
|
||||||
private BlockRef channelRoot;
|
private BlockRef channelRoot;
|
||||||
|
|
||||||
public String getOwnerLogin() { return ownerLogin; }
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
@@ -67,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public Integer getChannelTypeVersion() { return channelTypeVersion; }
|
public Integer getChannelTypeVersion() { return channelTypeVersion; }
|
||||||
public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = 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 BlockRef getChannelRoot() { return channelRoot; }
|
||||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||||
}
|
}
|
||||||
|
|||||||
+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.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.DbController;
|
||||||
import shine.db.dao.CurrentUsersDAO;
|
import shine.db.dao.CurrentUsersDAO;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import shine.db.entities.CurrentUserEntry;
|
import shine.db.entities.CurrentUserEntry;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
public class Net_GetUser_Handler implements JsonMessageHandler {
|
public class Net_GetUser_Handler implements JsonMessageHandler {
|
||||||
@@ -63,6 +67,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
|||||||
resp.setSolanaKey(u.getSolanaKey());
|
resp.setSolanaKey(u.getSolanaKey());
|
||||||
resp.setBlockchainKey(u.getBlockchainKey());
|
resp.setBlockchainKey(u.getBlockchainKey());
|
||||||
resp.setClientKey(u.getClientKey());
|
resp.setClientKey(u.getClientKey());
|
||||||
|
loadUserStats(resp, u.getLogin());
|
||||||
|
|
||||||
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
|
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
|
||||||
// автоматически восстанавливаем её для существующего пользователя.
|
// автоматически восстанавливаем её для существующего пользователя.
|
||||||
@@ -125,4 +130,35 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
return new String(out);
|
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 String serverLastGlobalHash;
|
||||||
private Long serverBlockchainSizeBytes;
|
private Long serverBlockchainSizeBytes;
|
||||||
private Long serverBlockchainSizeLimitBytes;
|
private Long serverBlockchainSizeLimitBytes;
|
||||||
|
private Integer ownedPublicChannelsCount;
|
||||||
|
private Integer followingUsersCount;
|
||||||
|
private Integer followingChannelsCount;
|
||||||
|
private Integer closeFriendsCount;
|
||||||
|
|
||||||
public Boolean getExists() { return exists; }
|
public Boolean getExists() { return exists; }
|
||||||
public void setExists(Boolean exists) { this.exists = 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 Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; }
|
||||||
public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = 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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -123,7 +123,7 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue;
|
if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue;
|
||||||
if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue;
|
if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue;
|
||||||
try {
|
try {
|
||||||
REMOTE.upsertUserSetting(remoteUrl, entry, true);
|
REMOTE.upsertUserSetting(remoteLogin, remoteUrl, entry, true);
|
||||||
delivered++;
|
delivered++;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e));
|
log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e));
|
||||||
|
|||||||
+16
-126
@@ -11,27 +11,19 @@ import shine.db.entities.BlockEntry;
|
|||||||
import shine.db.entities.SyncServerEntry;
|
import shine.db.entities.SyncServerEntry;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
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.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers.
|
* Фоновая репликация AddBlock через общий постоянный WSS-пул на серверы
|
||||||
|
* из локальной таблицы sync_servers.
|
||||||
*/
|
*/
|
||||||
public final class AddBlockSyncService {
|
public final class AddBlockSyncService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
|
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
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(
|
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
|
||||||
1,
|
1,
|
||||||
Math.max(2, Runtime.getRuntime().availableProcessors()),
|
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 {
|
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) {
|
if (wsUrl == null) {
|
||||||
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
|
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
|
||||||
partner.getLogin(), partner.getServerAddress());
|
partner.getLogin(), partner.getServerAddress());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock);
|
AddBlockPushResult firstTry = pushBlock(partner, blockchainName, currentBlock);
|
||||||
if (firstTry.ok()) {
|
if (firstTry.ok()) {
|
||||||
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
|
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
|
||||||
partner.getLogin(), blockchainName, blockNumber);
|
partner.getLogin(), blockchainName, blockNumber);
|
||||||
@@ -142,7 +134,7 @@ public final class AddBlockSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (BlockEntry blockEntry : missingBlocks) {
|
for (BlockEntry blockEntry : missingBlocks) {
|
||||||
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry);
|
AddBlockPushResult backfillResult = pushBlock(partner, blockchainName, blockEntry);
|
||||||
if (!backfillResult.ok()) {
|
if (!backfillResult.ok()) {
|
||||||
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
|
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
|
||||||
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
|
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
|
||||||
@@ -154,8 +146,8 @@ public final class AddBlockSyncService {
|
|||||||
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
|
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
|
private AddBlockPushResult pushBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry);
|
JsonNode response = sendAddBlock(partner, blockchainName, blockEntry);
|
||||||
int status = response.path("status").asInt(500);
|
int status = response.path("status").asInt(500);
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
return AddBlockPushResult.success();
|
return AddBlockPushResult.success();
|
||||||
@@ -172,31 +164,16 @@ public final class AddBlockSyncService {
|
|||||||
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
|
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
|
private JsonNode sendAddBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
String jsonTemplate = buildAddBlockJsonTemplate(blockchainName, blockEntry);
|
||||||
CountDownLatch openLatch = new CountDownLatch(1);
|
return ServerConnectionPool.getInstance().request(
|
||||||
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
partner.getLogin(),
|
||||||
|
partner.getServerAddress(),
|
||||||
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
jsonTemplate,
|
||||||
.connectTimeout(Duration.ofSeconds(6))
|
ServerConnectionPool.Priority.BULK);
|
||||||
.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();
|
private String buildAddBlockJsonTemplate(String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
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 String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
|
|
||||||
String prevHashHex = blockEntry.getBlockNumber() <= 0
|
String prevHashHex = blockEntry.getBlockNumber() <= 0
|
||||||
? ""
|
? ""
|
||||||
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
|
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
|
||||||
@@ -205,8 +182,6 @@ public final class AddBlockSyncService {
|
|||||||
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
|
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
|
||||||
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
|
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
|
||||||
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
|
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
|
||||||
String safeRequestId = MAPPER.writeValueAsString(requestId);
|
|
||||||
|
|
||||||
return """
|
return """
|
||||||
{
|
{
|
||||||
"op":"AddBlock",
|
"op":"AddBlock",
|
||||||
@@ -218,7 +193,7 @@ public final class AddBlockSyncService {
|
|||||||
"blockBytesB64":%s
|
"blockBytesB64":%s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
|
""".formatted("%s", safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] extractPrevHash32(byte[] blockBytes) {
|
private static byte[] extractPrevHash32(byte[] blockBytes) {
|
||||||
@@ -240,32 +215,6 @@ public final class AddBlockSyncService {
|
|||||||
return s.isEmpty() ? null : s;
|
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) {
|
private static String toHex(byte[] bytes) {
|
||||||
if (bytes == null) return "";
|
if (bytes == null) return "";
|
||||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
@@ -276,17 +225,6 @@ public final class AddBlockSyncService {
|
|||||||
return sb.toString();
|
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(
|
private record AddBlockPushResult(
|
||||||
boolean ok,
|
boolean ok,
|
||||||
int status,
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -149,7 +149,8 @@ public final class DmDeliveryCoordinator {
|
|||||||
completion.submit(() -> {
|
completion.submit(() -> {
|
||||||
try {
|
try {
|
||||||
if (!routeLogin.equals(ownServerLogin)) {
|
if (!routeLogin.equals(ownServerLogin)) {
|
||||||
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
REMOTE.receiveIncomingMessage(
|
||||||
|
routeLogin, route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
||||||
}
|
}
|
||||||
return new RouteAttempt(routeLogin, null);
|
return new RouteAttempt(routeLogin, null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -188,6 +189,7 @@ public final class DmDeliveryCoordinator {
|
|||||||
if (incoming == null || outgoing == null) return current;
|
if (incoming == null || outgoing == null) return current;
|
||||||
try {
|
try {
|
||||||
REMOTE.sendMessagePair(
|
REMOTE.sendMessagePair(
|
||||||
|
peer.getServerLogin(),
|
||||||
peer.getServerUrl(),
|
peer.getServerUrl(),
|
||||||
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
|
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
|
||||||
Base64.getEncoder().encodeToString(outgoing.getRawBlock()),
|
Base64.getEncoder().encodeToString(outgoing.getRawBlock()),
|
||||||
@@ -204,7 +206,7 @@ public final class DmDeliveryCoordinator {
|
|||||||
if (peer == null) return current;
|
if (peer == null) return current;
|
||||||
try {
|
try {
|
||||||
RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus(
|
RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus(
|
||||||
peer.getServerUrl(), current.getOutgoingMessageKey());
|
peer.getServerLogin(), peer.getServerUrl(), current.getOutgoingMessageKey());
|
||||||
if (remote.known() && remote.delivered()) {
|
if (remote.known() && remote.delivered()) {
|
||||||
return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis());
|
return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis());
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -27,12 +27,15 @@ public final class DmFederationService {
|
|||||||
String ownServerLogin = ownServerLogin();
|
String ownServerLogin = ownServerLogin();
|
||||||
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
|
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
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()) {
|
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) 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) {
|
} catch (Exception e) {
|
||||||
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, 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 (routeLogin == null) continue;
|
||||||
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
|
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
|
||||||
if (normalizedSource != null && normalizedSource.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) {
|
} catch (Exception e) {
|
||||||
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
|
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
|
||||||
@@ -79,9 +83,9 @@ public final class DmFederationService {
|
|||||||
for (UserAccessServerRouteEntry route : routes.values()) {
|
for (UserAccessServerRouteEntry route : routes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
if (oneMessageDelete) {
|
if (oneMessageDelete) {
|
||||||
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
|
REMOTE.deleteMessage(route.getServerLogin(), route.getServerUrl(), blobB64);
|
||||||
} else {
|
} else {
|
||||||
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
|
REMOTE.deleteConversation(route.getServerLogin(), route.getServerUrl(), blobB64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
+27
-132
@@ -2,37 +2,22 @@ package server.sync;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.ArrayList;
|
||||||
import java.util.List;
|
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.
|
* Минимальный клиент для межсерверных JSON-op запросов по WSS.
|
||||||
*/
|
*/
|
||||||
public final class RemoteBlockchainSyncClient {
|
public final class RemoteBlockchainSyncClient {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class);
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
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 {
|
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",
|
"op":"ListBlockchainHeads",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -62,8 +47,12 @@ public final class RemoteBlockchainSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception {
|
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);
|
String safeLogin = MAPPER.writeValueAsString(login);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"GetSyncUserProfile",
|
"op":"GetSyncUserProfile",
|
||||||
"requestId":%s,
|
"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);
|
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"GetBlockchainBlock",
|
"op":"GetBlockchainBlock",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -126,32 +123,12 @@ public final class RemoteBlockchainSyncClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
private JsonNode send(String serverLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||||
String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID());
|
return ServerConnectionPool.getInstance().request(
|
||||||
String json = jsonTemplate.formatted(requestId);
|
serverLogin,
|
||||||
String wsUrl = buildWsUrl(serverAddressRaw);
|
serverAddressRaw,
|
||||||
if (wsUrl == null) {
|
jsonTemplate,
|
||||||
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
ServerConnectionPool.Priority.BULK);
|
||||||
}
|
|
||||||
|
|
||||||
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 static String errorCode(JsonNode response) {
|
private static String errorCode(JsonNode response) {
|
||||||
@@ -161,40 +138,7 @@ public final class RemoteBlockchainSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String buildWsUrl(String serverAddressRaw) {
|
static String buildWsUrl(String serverAddressRaw) {
|
||||||
String host = normalizeHostLike(serverAddressRaw);
|
return ServerConnectionPool.buildWsUrl(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) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RemoteBlockchainHead(
|
public record RemoteBlockchainHead(
|
||||||
@@ -220,53 +164,4 @@ public final class RemoteBlockchainSyncClient {
|
|||||||
long blockchainSizeLimitBytes
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-91
@@ -3,37 +3,33 @@ package server.sync;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.ArrayList;
|
||||||
import java.util.List;
|
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-операций. */
|
/** Клиент стабильных межсерверных DM-операций. */
|
||||||
public final class RemoteDmSyncClient {
|
public final class RemoteDmSyncClient {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(6))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
public void sendMessagePair(
|
public void sendMessagePair(
|
||||||
String serverAddressRaw,
|
String serverAddressRaw,
|
||||||
String incomingBlobB64,
|
String incomingBlobB64,
|
||||||
String outgoingBlobB64,
|
String outgoingBlobB64,
|
||||||
String sourceServerLogin
|
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 {
|
) throws Exception {
|
||||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||||
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
||||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"ReceiveOutcomingMessage",
|
"op":"ReceiveOutcomingMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -50,10 +46,19 @@ public final class RemoteDmSyncClient {
|
|||||||
String serverAddressRaw,
|
String serverAddressRaw,
|
||||||
String incomingBlobB64,
|
String incomingBlobB64,
|
||||||
String sourceServerLogin
|
String sourceServerLogin
|
||||||
|
) throws Exception {
|
||||||
|
receiveIncomingMessage(null, serverAddressRaw, incomingBlobB64, sourceServerLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void receiveIncomingMessage(
|
||||||
|
String targetServerLogin,
|
||||||
|
String serverAddressRaw,
|
||||||
|
String incomingBlobB64,
|
||||||
|
String sourceServerLogin
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"ReceiveIncomingMessage",
|
"op":"ReceiveIncomingMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -66,8 +71,14 @@ public final class RemoteDmSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
|
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);
|
String messageKeyJson = MAPPER.writeValueAsString(messageKey);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"GetDmDeliveryStatus",
|
"op":"GetDmDeliveryStatus",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -94,7 +105,21 @@ public final class RemoteDmSyncClient {
|
|||||||
int maxBytes,
|
int maxBytes,
|
||||||
List<String> ackSyncIds
|
List<String> ackSyncIds
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
|
return dmSyncBatch(null, serverAddressRaw, ownerLogin, afterStoredAtMs,
|
||||||
|
afterMessageKey, limit, maxBytes, ackSyncIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RemoteDmBatch dmSyncBatch(
|
||||||
|
String targetServerLogin,
|
||||||
|
String serverAddressRaw,
|
||||||
|
String ownerLogin,
|
||||||
|
long afterStoredAtMs,
|
||||||
|
String afterMessageKey,
|
||||||
|
int limit,
|
||||||
|
int maxBytes,
|
||||||
|
List<String> ackSyncIds
|
||||||
|
) throws Exception {
|
||||||
|
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
|
||||||
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
|
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
|
||||||
limit, maxBytes, ackSyncIds);
|
limit, maxBytes, ackSyncIds);
|
||||||
}
|
}
|
||||||
@@ -156,8 +181,12 @@ public final class RemoteDmSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
|
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);
|
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"DeleteMessage",
|
"op":"DeleteMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -168,8 +197,12 @@ public final class RemoteDmSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception {
|
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);
|
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(targetServerLogin, serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"DeleteConversation",
|
"op":"DeleteConversation",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
@@ -179,27 +212,12 @@ public final class RemoteDmSyncClient {
|
|||||||
ensureOk("DeleteConversation", response);
|
ensureOk("DeleteConversation", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
private JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||||
String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID());
|
return ServerConnectionPool.getInstance().request(
|
||||||
String json = jsonTemplate.formatted(requestId);
|
targetServerLogin,
|
||||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
serverAddressRaw,
|
||||||
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
jsonTemplate,
|
||||||
|
ServerConnectionPool.Priority.REALTIME);
|
||||||
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 String toOptionalJsonField(String fieldName, String value) throws Exception {
|
private String toOptionalJsonField(String fieldName, String value) throws Exception {
|
||||||
@@ -215,60 +233,10 @@ public final class RemoteDmSyncClient {
|
|||||||
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void tryAbort(WebSocket webSocket) {
|
|
||||||
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {}
|
|
||||||
try { webSocket.abort(); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
|
public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
|
||||||
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
|
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
|
||||||
public record RemoteDmItem(
|
public record RemoteDmItem(
|
||||||
String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-54
@@ -1,70 +1,35 @@
|
|||||||
package server.sync;
|
package server.sync;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import java.net.URI;
|
/**
|
||||||
import java.net.http.HttpClient;
|
* Логический последовательный сеанс поверх общего постоянного WSS-пула.
|
||||||
import java.net.http.WebSocket;
|
* close() больше не закрывает физическое соединение с сервером.
|
||||||
import java.nio.ByteBuffer;
|
*/
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.CompletionStage;
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
|
|
||||||
/** Один последовательный WS-сеанс для синхронизации всех данных access-сервера. */
|
|
||||||
public final class RemoteSyncSession implements AutoCloseable {
|
public final class RemoteSyncSession implements AutoCloseable {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private final String serverLogin;
|
||||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
private final String serverAddress;
|
||||||
.connectTimeout(Duration.ofSeconds(6)).build();
|
|
||||||
private final LinkedBlockingQueue<String> responses = new LinkedBlockingQueue<>();
|
|
||||||
private final WebSocket webSocket;
|
|
||||||
|
|
||||||
|
/** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */
|
||||||
public RemoteSyncSession(String serverAddressRaw) throws Exception {
|
public RemoteSyncSession(String serverAddressRaw) throws Exception {
|
||||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
this(null, serverAddressRaw);
|
||||||
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
}
|
||||||
Listener listener = new Listener(responses);
|
|
||||||
webSocket = HTTP.newWebSocketBuilder().connectTimeout(Duration.ofSeconds(6))
|
public RemoteSyncSession(String serverLogin, String serverAddressRaw) {
|
||||||
.buildAsync(URI.create(wsUrl), listener).get(8, TimeUnit.SECONDS);
|
this.serverLogin = serverLogin;
|
||||||
|
this.serverAddress = serverAddressRaw;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized JsonNode send(String jsonTemplate) throws Exception {
|
public synchronized JsonNode send(String jsonTemplate) throws Exception {
|
||||||
String requestId = MAPPER.writeValueAsString("access-sync-" + UUID.randomUUID());
|
return ServerConnectionPool.getInstance().request(
|
||||||
webSocket.sendText(jsonTemplate.formatted(requestId), true).get(8, TimeUnit.SECONDS);
|
serverLogin,
|
||||||
String json = responses.poll(12, TimeUnit.SECONDS);
|
serverAddress,
|
||||||
if (json == null) throw new TimeoutException("WS response timeout");
|
jsonTemplate,
|
||||||
return MAPPER.readTree(json);
|
ServerConnectionPool.Priority.NORMAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {}
|
// Физическое соединение принадлежит ServerConnectionPool.
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Listener implements WebSocket.Listener {
|
|
||||||
private final LinkedBlockingQueue<String> responses;
|
|
||||||
private final StringBuilder text = new StringBuilder();
|
|
||||||
|
|
||||||
private Listener(LinkedBlockingQueue<String> responses) { this.responses = responses; }
|
|
||||||
@Override public void onOpen(WebSocket ws) { ws.request(1); }
|
|
||||||
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
|
|
||||||
text.append(data);
|
|
||||||
if (last) {
|
|
||||||
responses.offer(text.toString());
|
|
||||||
text.setLength(0);
|
|
||||||
}
|
|
||||||
ws.request(1);
|
|
||||||
return CompletableFuture.completedFuture(null);
|
|
||||||
}
|
|
||||||
@Override public CompletionStage<?> onBinary(WebSocket ws, ByteBuffer data, boolean last) {
|
|
||||||
ws.request(1);
|
|
||||||
return CompletableFuture.completedFuture(null);
|
|
||||||
}
|
|
||||||
@Override public void onError(WebSocket ws, Throwable error) {
|
|
||||||
responses.offer("{\"status\":500,\"code\":\"WS_ERROR\"}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-86
@@ -4,27 +4,22 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import shine.db.entities.UserSettingEntry;
|
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.ArrayList;
|
||||||
import java.util.List;
|
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 {
|
public final class RemoteUserSettingsSyncClient {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
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 {
|
public void upsertUserSetting(
|
||||||
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
|
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);
|
upsertUserSetting(session, entry, syncDelivery);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +64,20 @@ public final class RemoteUserSettingsSyncClient {
|
|||||||
int limit,
|
int limit,
|
||||||
int maxBytes
|
int maxBytes
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
|
return userSettingsSyncBatch(null, serverAddressRaw, ownerLogin,
|
||||||
|
afterTimeMs, afterSettingKey, limit, maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RemoteUserSettingsBatch userSettingsSyncBatch(
|
||||||
|
String targetServerLogin,
|
||||||
|
String serverAddressRaw,
|
||||||
|
String ownerLogin,
|
||||||
|
long afterTimeMs,
|
||||||
|
String afterSettingKey,
|
||||||
|
int limit,
|
||||||
|
int maxBytes
|
||||||
|
) throws Exception {
|
||||||
|
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
|
||||||
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
|
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,34 +137,6 @@ public final class RemoteUserSettingsSyncClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
|
||||||
String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID());
|
|
||||||
String json = jsonTemplate.formatted(requestId);
|
|
||||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
|
||||||
if (wsUrl == null) {
|
|
||||||
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
CompletableFuture<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) {
|
private void ensureOk(String op, JsonNode response) {
|
||||||
int status = response.path("status").asInt(500);
|
int status = response.path("status").asInt(500);
|
||||||
if (status >= 200 && status < 300) return;
|
if (status >= 200 && status < 300) return;
|
||||||
@@ -165,17 +145,6 @@ public final class RemoteUserSettingsSyncClient {
|
|||||||
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
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(
|
public record RemoteUserSettingsBatch(
|
||||||
long nextTimeMs,
|
long nextTimeMs,
|
||||||
String nextSettingKey,
|
String nextSettingKey,
|
||||||
@@ -194,35 +163,4 @@ public final class RemoteUserSettingsSyncClient {
|
|||||||
String signature
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ public final class BlockchainResyncRecoveryOnStartup {
|
|||||||
blockchainName, partnerLogin, partnerAddress);
|
blockchainName, partnerLogin, partnerAddress);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads = REMOTE.listBlockchainHeads(partnerAddress);
|
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
|
||||||
|
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
|
||||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
|
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
|
||||||
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
|
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ public final class PeriodicBlockchainSyncService {
|
|||||||
if (partnerLogin == null) return;
|
if (partnerLogin == null) return;
|
||||||
|
|
||||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads =
|
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads =
|
||||||
REMOTE.listBlockchainHeads(partner.getServerAddress());
|
REMOTE.listBlockchainHeads(partner.getLogin(), partner.getServerAddress());
|
||||||
|
|
||||||
for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) {
|
for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) {
|
||||||
if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
|
if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
|
||||||
@@ -170,7 +170,8 @@ public final class PeriodicBlockchainSyncService {
|
|||||||
int fromBlockNumber = Math.max(localLast + 1, 0);
|
int fromBlockNumber = Math.max(localLast + 1, 0);
|
||||||
for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
||||||
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
||||||
REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
|
REMOTE.getBlockchainBlock(
|
||||||
|
partner.getLogin(), partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
|
||||||
if (remoteBlock == null) {
|
if (remoteBlock == null) {
|
||||||
log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
||||||
partnerLogin, remoteHead.blockchainName(), blockNumber);
|
partnerLogin, remoteHead.blockchainName(), blockNumber);
|
||||||
@@ -284,7 +285,8 @@ public final class PeriodicBlockchainSyncService {
|
|||||||
String localPrevHash = "";
|
String localPrevHash = "";
|
||||||
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
||||||
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
||||||
REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber);
|
REMOTE.getBlockchainBlock(
|
||||||
|
partner.getLogin(), partner.getServerAddress(), blockchainName, blockNumber);
|
||||||
if (remoteBlock == null) {
|
if (remoteBlock == null) {
|
||||||
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
||||||
partnerLogin, blockchainName, blockNumber);
|
partnerLogin, blockchainName, blockNumber);
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ public final class PeriodicUserSettingsSyncService {
|
|||||||
boolean bootstrapCompleted = false;
|
boolean bootstrapCompleted = false;
|
||||||
|
|
||||||
int appliedDm;
|
int appliedDm;
|
||||||
try (RemoteSyncSession session = new RemoteSyncSession(route.getServerUrl())) {
|
try (RemoteSyncSession session = new RemoteSyncSession(route.getServerLogin(), route.getServerUrl())) {
|
||||||
for (int page = 0; page < maxPages; page++) {
|
for (int page = 0; page < maxPages; page++) {
|
||||||
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
|
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -83,3 +83,11 @@ WebSocket-эндпоинт для одного соединения.
|
|||||||
|
|
||||||
- `WsServer` = сервер, который слушает порт и вешает `/ws`.
|
- `WsServer` = сервер, который слушает порт и вешает `/ws`.
|
||||||
- `BlockchainWsEndpoint` = обработчик одного WebSocket-подключения, мост между сетью и логикой.
|
- `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.PeriodicUserSettingsSyncService;
|
||||||
import server.sync.SolanaUsersSyncStartupService;
|
import server.sync.SolanaUsersSyncStartupService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
|
import server.sync.ServerConnectionPool;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -104,6 +105,10 @@ public final class WsServer {
|
|||||||
|
|
||||||
server.start();
|
server.start();
|
||||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||||
|
ServerConnectionPool.getInstance().startOrLog();
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(
|
||||||
|
() -> ServerConnectionPool.getInstance().close(),
|
||||||
|
"server-connection-pool-shutdown"));
|
||||||
PeriodicDmSyncService.startOrLog();
|
PeriodicDmSyncService.startOrLog();
|
||||||
PeriodicUserSettingsSyncService.startOrLog();
|
PeriodicUserSettingsSyncService.startOrLog();
|
||||||
server.join();
|
server.join();
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ dm.worker.dueLimit=100
|
|||||||
dm.sync.batchLimit=200
|
dm.sync.batchLimit=200
|
||||||
dm.sync.batchMaxBytes=3000000
|
dm.sync.batchMaxBytes=3000000
|
||||||
dm.sync.maxPagesPerPeer=20
|
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.url=
|
||||||
server.info.physicalRegion=
|
server.info.physicalRegion=
|
||||||
server.info.description=
|
server.info.description=
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import blockchain.body.ConnectionBody;
|
|||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
import blockchain.body.HeaderBody;
|
import blockchain.body.HeaderBody;
|
||||||
import blockchain.body.TextBody;
|
import blockchain.body.TextBody;
|
||||||
|
import shine.db.DbController;
|
||||||
import test.it.blockchain.AddBlockSender;
|
import test.it.blockchain.AddBlockSender;
|
||||||
import test.it.blockchain.ChainState;
|
import test.it.blockchain.ChainState;
|
||||||
import test.it.utils.TestConfig;
|
import test.it.utils.TestConfig;
|
||||||
@@ -13,6 +14,10 @@ import test.it.utils.log.TestResult;
|
|||||||
import test.it.utils.ws.WsSession;
|
import test.it.utils.ws.WsSession;
|
||||||
|
|
||||||
import java.time.Duration;
|
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.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@@ -117,6 +122,27 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
st1.registerTextChannelRoot(newsRootBlock, newsRootHash);
|
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"
|
// POST #0 в канал "News"
|
||||||
int newsPost0Block;
|
int newsPost0Block;
|
||||||
byte[] newsPost0Hash;
|
byte[] newsPost0Hash;
|
||||||
@@ -188,7 +214,21 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
bch1, newsRootBlock, newsRootHash,
|
bch1, newsRootBlock, newsRootHash,
|
||||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
// 3) FRIEND взаимно (на HEADER)
|
// 3) U2 подписался на второй канал U1 "Updates"
|
||||||
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||||
|
bch1, updatesRootBlock, updatesRootHash,
|
||||||
|
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
|
assertEquals(2, countConnectionsByOwner(u2, u1),
|
||||||
|
"U2 должен иметь две отдельные записи подписки на каналы U1");
|
||||||
|
assertEquals(2, countFollowingChannels(u2),
|
||||||
|
"following_channels_count должен учитывать два разных канала одного владельца");
|
||||||
|
assertEquals(1, countSubscribers(bch1, newsRootBlock, newsRootHash),
|
||||||
|
"У канала News должен быть 1 подписчик");
|
||||||
|
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
|
||||||
|
"У канала Updates должен быть 1 подписчик");
|
||||||
|
|
||||||
|
// 4) FRIEND взаимно (на HEADER)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
bch2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: FRIEND", t);
|
"U1 -> U2: FRIEND", t);
|
||||||
@@ -197,7 +237,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
bch1, u1HeaderBlock, u1HeaderHash,
|
bch1, u1HeaderBlock, u1HeaderHash,
|
||||||
"U2 -> U1: FRIEND", t);
|
"U2 -> U1: FRIEND", t);
|
||||||
|
|
||||||
// 4) CONTACT несколько
|
// 5) CONTACT несколько
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
bch2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: CONTACT", t);
|
"U1 -> U2: CONTACT", t);
|
||||||
@@ -236,7 +276,21 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
bch3, u3HeaderBlock, u3HeaderHash,
|
bch3, u3HeaderBlock, u3HeaderHash,
|
||||||
"U1 -> U3: CONTACT", t);
|
"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,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
bch2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: UNCONTACT", t);
|
"U1 -> U2: UNCONTACT", t);
|
||||||
@@ -288,4 +342,58 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
toBlockHash32
|
toBlockHash32
|
||||||
), timeout);
|
), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
backup.schema.version=2
|
backup.schema.version=2
|
||||||
backup.full.version=3
|
backup.full.version=4
|
||||||
last.full.backup.date=2026-08-08
|
last.full.backup.date=2026-08-25
|
||||||
|
|||||||
@@ -57,7 +57,11 @@
|
|||||||
"serverLastGlobalNumber": 128,
|
"serverLastGlobalNumber": 128,
|
||||||
"serverLastGlobalHash": "4f...ab",
|
"serverLastGlobalHash": "4f...ab",
|
||||||
"serverBlockchainSizeBytes": 45212,
|
"serverBlockchainSizeBytes": 45212,
|
||||||
"serverBlockchainSizeLimitBytes": 100000
|
"serverBlockchainSizeLimitBytes": 100000,
|
||||||
|
"ownedPublicChannelsCount": 3,
|
||||||
|
"followingUsersCount": 18,
|
||||||
|
"followingChannelsCount": 27,
|
||||||
|
"closeFriendsCount": 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -68,6 +72,10 @@
|
|||||||
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
|
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
|
||||||
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
|
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
|
||||||
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
|
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
|
||||||
|
- `ownedPublicChannelsCount` — количество публичных каналов, владельцем которых является пользователь;
|
||||||
|
- `followingUsersCount` — количество пользователей, на которых подписан пользователь;
|
||||||
|
- `followingChannelsCount` — количество публичных каналов, на которые подписан пользователь;
|
||||||
|
- `closeFriendsCount` — количество близких друзей пользователя.
|
||||||
|
|
||||||
### Успешный ответ: пользователя нет
|
### Успешный ответ: пользователя нет
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
"avaSha256": "0123...",
|
"avaSha256": "0123...",
|
||||||
"avaSize": 248193,
|
"avaSize": 248193,
|
||||||
"metaUpdatedAtMs": 1760000000000,
|
"metaUpdatedAtMs": 1760000000000,
|
||||||
|
"subscribersCount": 128,
|
||||||
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||||||
},
|
},
|
||||||
"metaEvents": [
|
"metaEvents": [
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||||
| `GetBlockchainBlock` | `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 |
|
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
||||||
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
||||||
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
|
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
|
|
||||||
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
|
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
|
||||||
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
|
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
|
||||||
|
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
|
||||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||||
|
|||||||
@@ -83,7 +83,10 @@
|
|||||||
|
|
||||||
## 5. Синхронизация
|
## 5. Синхронизация
|
||||||
|
|
||||||
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс открывает один последовательный WS-сеанс с peer: сначала синхронизирует настройки, затем забирает `DmSyncBatch`.
|
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс
|
||||||
|
использует один логический последовательный сеанс поверх постоянного WSS-пула:
|
||||||
|
сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Завершение
|
||||||
|
логического сеанса не закрывает физический сокет.
|
||||||
|
|
||||||
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
|
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
|
||||||
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
|
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
|
||||||
|
|||||||
@@ -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
|
## 2026-08-12 13:00:00 +0400
|
||||||
- Базовый коммит-ориентир: `working tree`.
|
- Базовый коммит-ориентир: `working tree`.
|
||||||
- Канонический текстовый формат служебных тегов сокращён:
|
- Канонический текстовый формат служебных тегов сокращён:
|
||||||
|
|||||||
@@ -42,4 +42,6 @@
|
|||||||
## Обязательное сопровождение
|
## Обязательное сопровождение
|
||||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
||||||
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
|
- Обычный `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` с датой/временем и хэшем коммита-основания.
|
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||||
|
|||||||
@@ -183,16 +183,20 @@ Full resync запускается только тогда, когда:
|
|||||||
|
|
||||||
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
|
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
|
||||||
|
|
||||||
## 5. Возможное развитие server-to-server транспорта
|
## 5. Реализованный постоянный server-to-server транспорт
|
||||||
|
|
||||||
Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth.
|
Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
|
||||||
|
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
|
||||||
|
|
||||||
### 5.1 Межсерверное соединение
|
### 5.1 Межсерверное соединение
|
||||||
|
|
||||||
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
|
- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
|
||||||
- Адрес партнёра определяется по `server_address` из его Solana PDA.
|
- Адрес партнёра определяется по `server_address` из его Solana PDA.
|
||||||
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
|
- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
|
||||||
- При разрыве — переподключение с экспоненциальным backoff.
|
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
|
||||||
|
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
|
||||||
|
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
|
||||||
|
- Один физический канал переиспользуют DM, настройки и blockchain.
|
||||||
|
|
||||||
### 5.2 Доставка новых данных (push)
|
### 5.2 Доставка новых данных (push)
|
||||||
|
|
||||||
@@ -246,19 +250,21 @@ Full resync запускается только тогда, когда:
|
|||||||
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
|
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
|
||||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||||
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||||
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
|
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
|
||||||
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
|
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
|
||||||
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
||||||
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
|
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
|
||||||
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
|
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
|
||||||
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
|
||||||
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
||||||
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
||||||
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
|
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
|
||||||
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
|
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
|
||||||
| Криптографическая server-to-server авторизация DM | Нужна реализация |
|
| Криптографическая server-to-server авторизация DM | Нужна реализация |
|
||||||
|
|
||||||
Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов.
|
Текущая версия сервера использует постоянный WSS-пул для существующих
|
||||||
|
server-to-server JSON-операций. Отдельной будущей задачей остаётся
|
||||||
|
криптографическая авторизация server-to-server вызовов.
|
||||||
|
|
||||||
Следующие отдельные шаги после текущего этапа:
|
Следующие отдельные шаги после текущего этапа:
|
||||||
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
@@ -93,6 +93,7 @@ import * as chatView from './pages/chat-view.js?v=202608221218';
|
|||||||
import * as userProfileView from './pages/user-profile-view.js';
|
import * as userProfileView from './pages/user-profile-view.js';
|
||||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||||
import * as channelView from './pages/channel-view.js';
|
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 channelThreadView from './pages/channel-thread-view.js';
|
||||||
import * as addChannelView from './pages/add-channel-view.js';
|
import * as addChannelView from './pages/add-channel-view.js';
|
||||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||||
@@ -155,6 +156,7 @@ const routes = {
|
|||||||
user: userProfileView,
|
user: userProfileView,
|
||||||
'channels-list': channelsList,
|
'channels-list': channelsList,
|
||||||
'channel-view': channelView,
|
'channel-view': channelView,
|
||||||
|
'channel-about-view': channelAboutView,
|
||||||
'channel-thread-view': channelThreadView,
|
'channel-thread-view': channelThreadView,
|
||||||
'add-channel-view': addChannelView,
|
'add-channel-view': addChannelView,
|
||||||
'add-personal-public-chat-view': addPersonalPublicChatView,
|
'add-personal-public-chat-view': addPersonalPublicChatView,
|
||||||
@@ -212,6 +214,7 @@ const GUEST_ALLOWED_PAGES = new Set([
|
|||||||
'network-view',
|
'network-view',
|
||||||
'channels-list',
|
'channels-list',
|
||||||
'channel-view',
|
'channel-view',
|
||||||
|
'channel-about-view',
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
'user',
|
'user',
|
||||||
'contact-search-view',
|
'contact-search-view',
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg';
|
||||||
|
|
||||||
|
export function createShineConnectionsLogo({ className = '' } = {}) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = SHINE_CONNECTIONS_LOGO_SRC;
|
||||||
|
img.alt = '';
|
||||||
|
img.setAttribute('aria-hidden', 'true');
|
||||||
|
img.className = String(className || '').trim();
|
||||||
|
return img;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
|
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||||
|
|
||||||
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
|
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
|
||||||
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
|
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
|
||||||
@@ -8,7 +9,7 @@ import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
|||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
|
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
|
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true },
|
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
|
||||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
|
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
|
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { authService, state } from '../state.js';
|
||||||
|
import { navigateBack } from '../router.js';
|
||||||
|
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||||
|
import { showToast } from '../services/channels-ux.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'channel-about-view', title: 'О канале' };
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHash(hash) {
|
||||||
|
const normalized = String(hash || '').trim().toLowerCase();
|
||||||
|
return normalized || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafeInt(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash) {
|
||||||
|
const ownerBch = String(ownerBlockchainName || '').trim().toLowerCase();
|
||||||
|
const rootNo = Number(channelRootBlockNumber);
|
||||||
|
const rootHash = normalizeHash(channelRootBlockHash);
|
||||||
|
const rows = Object.values(state.channelsIndex || {});
|
||||||
|
return rows.find((row) => (
|
||||||
|
String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
||||||
|
&& Number(row?.channel?.channelRoot?.blockNumber) === rootNo
|
||||||
|
&& normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash
|
||||||
|
)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelLink(route) {
|
||||||
|
if (!route) return '';
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.pathname = `/${String(route).replace(/^\/+/, '')}`;
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsText(value) {
|
||||||
|
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate, route, chrome }) {
|
||||||
|
const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim();
|
||||||
|
const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber);
|
||||||
|
const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash);
|
||||||
|
const channelRoute = makeShineChannelRootRoute({
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelRootBlockNumber,
|
||||||
|
channelRootBlockHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||||
|
|
||||||
|
const topbar = renderHeader({
|
||||||
|
title: 'О канале',
|
||||||
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channelRoute) navigate(channelRoute);
|
||||||
|
},
|
||||||
|
ariaLabel: 'Назад',
|
||||||
|
title: 'Назад',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card stack channel-about-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="stack" id="channel-about-content">
|
||||||
|
<div class="meta-muted">Загрузка данных канала…</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'meta-muted screen-footer';
|
||||||
|
footer.textContent = 'О канале (channel-about-view)';
|
||||||
|
|
||||||
|
screen.append(card, footer);
|
||||||
|
|
||||||
|
const renderContent = (channel) => {
|
||||||
|
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||||
|
const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim();
|
||||||
|
const description = String(channel?.channelDescription || channel?.description || '').trim();
|
||||||
|
const subscribersCount = Number(channel?.subscribersCount || 0);
|
||||||
|
const aboutRoute = makeShineChannelRootRoute({
|
||||||
|
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber,
|
||||||
|
channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash,
|
||||||
|
});
|
||||||
|
const channelLinkRoute = makeShineChannelShortRoute({
|
||||||
|
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
|
||||||
|
channelName: channel?.channelName || '',
|
||||||
|
});
|
||||||
|
const channelLink = buildChannelLink(channelLinkRoute);
|
||||||
|
const changedAtMs = Number(channel?.metaUpdatedAtMs || 0);
|
||||||
|
const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—';
|
||||||
|
const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен';
|
||||||
|
|
||||||
|
const content = card.querySelector('#channel-about-content');
|
||||||
|
if (!content) return;
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="channel-profile-modal-head">
|
||||||
|
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="channel-meta-details-grid">
|
||||||
|
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
||||||
|
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
|
||||||
|
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
|
||||||
|
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
|
||||||
|
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
|
||||||
|
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
|
||||||
|
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
||||||
|
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
|
||||||
|
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||||
|
if (!channelLinkRoute) return;
|
||||||
|
navigate(channelLinkRoute);
|
||||||
|
});
|
||||||
|
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||||||
|
if (!channelLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(channelLink);
|
||||||
|
showToast('Ссылка скопирована');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash);
|
||||||
|
if (cached?.channel) {
|
||||||
|
renderContent({
|
||||||
|
...cached.channel,
|
||||||
|
subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0,
|
||||||
|
});
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const payload = await authService.getChannelMessages({
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelRootBlockNumber,
|
||||||
|
channelRootBlockHash,
|
||||||
|
}, 1, 'asc', String(state.session.login || '').trim());
|
||||||
|
renderContent(payload?.channel || {});
|
||||||
|
} catch (error) {
|
||||||
|
const content = card.querySelector('#channel-about-content');
|
||||||
|
if (content) {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
||||||
|
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
extractLoginFromBlockchainName,
|
extractLoginFromBlockchainName,
|
||||||
makeProfileRoute,
|
makeProfileRoute,
|
||||||
makeShineMessageRoute,
|
makeShineMessageRoute,
|
||||||
|
makeShineChannelAboutRoute,
|
||||||
} from '../services/shine-routes.js';
|
} from '../services/shine-routes.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
@@ -2550,13 +2551,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
channelHeaderButton.disabled = false;
|
channelHeaderButton.disabled = false;
|
||||||
channelHeaderButton.onclick = (event) => {
|
channelHeaderButton.onclick = (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
openAboutChannelModal(apiData.channel, {
|
const aboutRoute = makeShineChannelAboutRoute({
|
||||||
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
|
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
|
||||||
onEdit: () => openEditChannelModal({
|
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
|
||||||
channel: apiData.channel,
|
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
|
||||||
onSave: onEditChannelMeta,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
if (aboutRoute) navigate(aboutRoute);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||||
import { renderAvatar } from '../components/avatar-image.js';
|
import { renderAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
@@ -64,10 +65,9 @@ function buildChannelRouteFromSummary(summary, fallbackId) {
|
|||||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
||||||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||||
return makeShineChannelRoute({
|
return makeShineChannelShortRoute({
|
||||||
ownerLogin,
|
|
||||||
ownerBlockchainName: ownerBch,
|
ownerBlockchainName: ownerBch,
|
||||||
channelName: channelName || fallbackId,
|
channelName: channelName || fallbackId || ownerLogin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
@@ -30,11 +31,12 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
|
|||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
|
|
||||||
function createChatHeaderParts(login) {
|
function createChatHeaderParts(login, navigate) {
|
||||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||||
|
|
||||||
const avatarSlot = document.createElement('span');
|
const avatarSlot = document.createElement('span');
|
||||||
@@ -47,6 +49,13 @@ function createChatHeaderParts(login) {
|
|||||||
});
|
});
|
||||||
avatarSlot.append(initialAvatar);
|
avatarSlot.append(initialAvatar);
|
||||||
|
|
||||||
|
const avatarButton = document.createElement('button');
|
||||||
|
avatarButton.type = 'button';
|
||||||
|
avatarButton.className = 'chat-header-avatar-btn';
|
||||||
|
avatarButton.title = `Профиль ${cleanLogin}`;
|
||||||
|
avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`);
|
||||||
|
avatarButton.append(avatarSlot);
|
||||||
|
|
||||||
const loginEl = document.createElement('span');
|
const loginEl = document.createElement('span');
|
||||||
loginEl.className = 'chat-header-login';
|
loginEl.className = 'chat-header-login';
|
||||||
loginEl.setAttribute('role', 'heading');
|
loginEl.setAttribute('role', 'heading');
|
||||||
@@ -75,7 +84,23 @@ function createChatHeaderParts(login) {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
return { centerNode: loginEl, avatarSlot };
|
const connectionsButton = document.createElement('button');
|
||||||
|
connectionsButton.type = 'button';
|
||||||
|
connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn';
|
||||||
|
connectionsButton.title = `Связи ${cleanLogin}`;
|
||||||
|
connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`);
|
||||||
|
connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' }));
|
||||||
|
connectionsButton.addEventListener('click', () => {
|
||||||
|
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||||
|
navigate(makeProfileLinksRoute(cleanLogin));
|
||||||
|
});
|
||||||
|
|
||||||
|
avatarButton.addEventListener('click', () => {
|
||||||
|
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||||
|
navigate(makeProfileRoute(cleanLogin));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { centerNode: loginEl, avatarButton, connectionsButton };
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
@@ -293,7 +318,6 @@ function openChatActionsMenu({
|
|||||||
anchorY = 0,
|
anchorY = 0,
|
||||||
onCall,
|
onCall,
|
||||||
onVideoCall,
|
onVideoCall,
|
||||||
onInstantVideoCall,
|
|
||||||
onClearHistory,
|
onClearHistory,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
}) {
|
}) {
|
||||||
@@ -305,8 +329,7 @@ function openChatActionsMenu({
|
|||||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Видеозвонок</button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
|
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
||||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,10 +381,6 @@ function openChatActionsMenu({
|
|||||||
close();
|
close();
|
||||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||||
});
|
});
|
||||||
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
|
|
||||||
close();
|
|
||||||
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
|
|
||||||
});
|
|
||||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||||
close();
|
close();
|
||||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||||
@@ -993,7 +1012,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const log = document.createElement('div');
|
const log = document.createElement('div');
|
||||||
log.className = 'messages-log dm-messages-log';
|
log.className = 'messages-log dm-messages-log';
|
||||||
|
|
||||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
||||||
const chatHeader = renderHeader({
|
const chatHeader = renderHeader({
|
||||||
centerNode: chatHeaderParts.centerNode,
|
centerNode: chatHeaderParts.centerNode,
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
@@ -1016,7 +1035,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||||
onCall: () => handleStartCall('audio'),
|
onCall: () => handleStartCall('audio'),
|
||||||
onVideoCall: () => handleStartCall('video'),
|
onVideoCall: () => handleStartCall('video'),
|
||||||
onInstantVideoCall: () => handleStartCall('instant_video'),
|
|
||||||
onClearHistory: async () => {
|
onClearHistory: async () => {
|
||||||
openChatConfirmModal({
|
openChatConfirmModal({
|
||||||
title: 'Очистить историю?',
|
title: 'Очистить историю?',
|
||||||
@@ -1064,7 +1082,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
||||||
|
chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton);
|
||||||
chrome?.setTopbar(chatHeader);
|
chrome?.setTopbar(chatHeader);
|
||||||
|
|
||||||
if (!isKnownContact) {
|
if (!isKnownContact) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
@@ -167,15 +168,11 @@ function compareChatRows(a, b) {
|
|||||||
export function render({ navigate, chrome }) {
|
export function render({ navigate, chrome }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
const login = String(state.session.login || '').trim();
|
|
||||||
const head = document.createElement('header');
|
const head = document.createElement('header');
|
||||||
head.className = 'dm-head';
|
head.className = 'dm-head';
|
||||||
head.innerHTML = `
|
head.innerHTML = `
|
||||||
<div class="dm-head-brand">
|
<div class="dm-head-brand">
|
||||||
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||||
<div class="dm-head-id">
|
|
||||||
<span class="dm-head-name"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="dm-head-title">Чаты</h1>
|
<h1 class="dm-head-title">Чаты</h1>
|
||||||
<div class="dm-head-menu-wrap">
|
<div class="dm-head-menu-wrap">
|
||||||
@@ -191,6 +188,9 @@ export function render({ navigate, chrome }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
head.querySelector('.dm-head-logo-wrap')?.append(
|
||||||
|
createShineConnectionsLogo({ className: 'dm-head-logo' }),
|
||||||
|
);
|
||||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||||
menuButton?.append(createOverflowDots());
|
menuButton?.append(createOverflowDots());
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
PROFILE_GENDER_FEMALE,
|
||||||
PROFILE_GENDER_MALE,
|
PROFILE_GENDER_MALE,
|
||||||
@@ -223,6 +223,12 @@ export function render({ navigate, chrome }) {
|
|||||||
let currentToggles = [];
|
let currentToggles = [];
|
||||||
let currentGender = 'unknown';
|
let currentGender = 'unknown';
|
||||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
|
let currentStats = {
|
||||||
|
ownedPublicChannelsCount: 0,
|
||||||
|
followingUsersCount: 0,
|
||||||
|
followingChannelsCount: 0,
|
||||||
|
closeFriendsCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
function syncIdentity() {
|
function syncIdentity() {
|
||||||
if (!identityEl) return;
|
if (!identityEl) return;
|
||||||
@@ -269,6 +275,21 @@ export function render({ navigate, chrome }) {
|
|||||||
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
|
||||||
|
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
|
||||||
|
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
|
||||||
|
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
|
||||||
|
];
|
||||||
|
stats.forEach((stat) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'card profile-param-item row';
|
||||||
|
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
|
||||||
|
listWrap.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
officialBtn?.classList.add('profile-badge-trigger');
|
officialBtn?.classList.add('profile-badge-trigger');
|
||||||
shineBtn?.classList.add('profile-badge-trigger');
|
shineBtn?.classList.add('profile-badge-trigger');
|
||||||
officialBtn?.addEventListener('click', () => {
|
officialBtn?.addEventListener('click', () => {
|
||||||
@@ -286,6 +307,7 @@ export function render({ navigate, chrome }) {
|
|||||||
|
|
||||||
function renderFields(fields) {
|
function renderFields(fields) {
|
||||||
listWrap.innerHTML = '';
|
listWrap.innerHTML = '';
|
||||||
|
renderStats();
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'card profile-param-item row';
|
row.className = 'card profile-param-item row';
|
||||||
@@ -317,6 +339,12 @@ export function render({ navigate, chrome }) {
|
|||||||
];
|
];
|
||||||
currentGender = 'unknown';
|
currentGender = 'unknown';
|
||||||
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
|
currentStats = {
|
||||||
|
ownedPublicChannelsCount: 0,
|
||||||
|
followingUsersCount: 0,
|
||||||
|
followingChannelsCount: 0,
|
||||||
|
closeFriendsCount: 0,
|
||||||
|
};
|
||||||
syncIdentity();
|
syncIdentity();
|
||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
@@ -325,11 +353,20 @@ export function render({ navigate, chrome }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await loadProfileSnapshot(login);
|
const [snapshot, user] = await Promise.all([
|
||||||
|
loadProfileSnapshot(login),
|
||||||
|
authService.getUser(login).catch(() => ({})),
|
||||||
|
]);
|
||||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
||||||
currentGender = snapshot.gender || 'unknown';
|
currentGender = snapshot.gender || 'unknown';
|
||||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
|
currentStats = {
|
||||||
|
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||||
|
followingUsersCount: Number(user?.followingUsersCount || 0),
|
||||||
|
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||||
|
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||||
|
};
|
||||||
syncIdentity();
|
syncIdentity();
|
||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
|
|||||||
+45
-1
@@ -26,6 +26,7 @@ const PRETTY_PATHS = new Map([
|
|||||||
['add-channel-view', 'channels/new'],
|
['add-channel-view', 'channels/new'],
|
||||||
['add-personal-public-chat-view', 'channels/new-public-chat'],
|
['add-personal-public-chat-view', 'channels/new-public-chat'],
|
||||||
['channel-view', 'channel'],
|
['channel-view', 'channel'],
|
||||||
|
['channel-about-view', 'channel/about'],
|
||||||
['channel-thread-view', 'thread'],
|
['channel-thread-view', 'thread'],
|
||||||
['network-view', 'network'],
|
['network-view', 'network'],
|
||||||
['notifications-view', 'notifications'],
|
['notifications-view', 'notifications'],
|
||||||
@@ -52,6 +53,10 @@ const PRETTY_PATHS = new Map([
|
|||||||
['remote-addblock-session-view', 'remote-addblock-session'],
|
['remote-addblock-session-view', 'remote-addblock-session'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function looksLikeBlockchainName(value) {
|
||||||
|
return /^.+-\d+$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
export const PRE_AUTH_PAGES = [
|
export const PRE_AUTH_PAGES = [
|
||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
@@ -162,6 +167,34 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (segments.length >= 2 && looksLikeBlockchainName(segments[0])) {
|
||||||
|
const ownerBlockchainName = decodePart(segments[0]);
|
||||||
|
const channelName = decodePart(segments[1] || '');
|
||||||
|
const sub = decodePart(segments[2] || '').toLowerCase();
|
||||||
|
if (ownerBlockchainName && channelName) {
|
||||||
|
if (sub === 'about') {
|
||||||
|
return {
|
||||||
|
pageId: 'channel-about-view',
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: '',
|
||||||
|
channelRootBlockHash: '',
|
||||||
|
channelId: '',
|
||||||
|
channelName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pageId: 'channel-view',
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelName,
|
||||||
|
channelId: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'chat' || pageId === 'chat-view') {
|
if (pageId === 'chat' || pageId === 'chat-view') {
|
||||||
return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||||
}
|
}
|
||||||
@@ -225,6 +258,17 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pageId === 'channel') {
|
if (pageId === 'channel') {
|
||||||
|
if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') {
|
||||||
|
return {
|
||||||
|
pageId: 'channel-about-view',
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName: decodePart(segments[1]),
|
||||||
|
channelRootBlockNumber: segments[2] || '',
|
||||||
|
channelRootBlockHash: segments[3] || '',
|
||||||
|
channelId: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (segments.length >= 4) {
|
if (segments.length >= 4) {
|
||||||
return {
|
return {
|
||||||
pageId: 'channel-view',
|
pageId: 'channel-view',
|
||||||
@@ -408,7 +452,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'solana-users-init-view'
|
pageId === 'solana-users-init-view'
|
||||||
) return 'profile-view';
|
) return 'profile-view';
|
||||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||||
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||||
if (pageId === 'user') return 'messages-list';
|
if (pageId === 'user') return 'messages-list';
|
||||||
return 'profile-view';
|
return 'profile-view';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function getCallTitleText(mode) {
|
|||||||
return 'Видеозвонок';
|
return 'Видеозвонок';
|
||||||
}
|
}
|
||||||
if (normalizeCallMode(mode) === CALL_MODE_VIDEO) {
|
if (normalizeCallMode(mode) === CALL_MODE_VIDEO) {
|
||||||
return 'Звонок с поддержкой видео';
|
return 'Видеозвонок';
|
||||||
}
|
}
|
||||||
return 'Звонок';
|
return 'Звонок';
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ function getIncomingCallStatusText(peerLogin, mode) {
|
|||||||
return `Входящий видеозвонок от ${name}`;
|
return `Входящий видеозвонок от ${name}`;
|
||||||
}
|
}
|
||||||
if (isVideoCallMode(mode)) {
|
if (isVideoCallMode(mode)) {
|
||||||
return `Вам звонит ${name} (звонок с поддержкой видео)`;
|
return `Входящий видеозвонок от ${name}`;
|
||||||
}
|
}
|
||||||
return `Вам звонит ${name}`;
|
return `Вам звонит ${name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,26 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
|
|||||||
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makeShineChannelRootRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
|
||||||
|
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||||
|
const rootNo = String(channelRootBlockNumber || '').trim();
|
||||||
|
const rootHash = String(channelRootBlockHash || '').trim();
|
||||||
|
if (!ownerBch || !rootNo || !rootHash) return '';
|
||||||
|
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(rootNo)}/${encodeRoutePart(rootHash)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
|
||||||
|
const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash });
|
||||||
|
return base ? `${base}/about` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) {
|
||||||
|
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||||
|
const chName = String(channelName || '').trim();
|
||||||
|
if (!ownerBch || !chName) return '';
|
||||||
|
return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
||||||
const msgBch = String(messageBlockchainName || '').trim();
|
const msgBch = String(messageBlockchainName || '').trim();
|
||||||
const msgNo = String(messageBlockNumber || '').trim();
|
const msgNo = String(messageBlockNumber || '').trim();
|
||||||
|
|||||||
@@ -5927,6 +5927,14 @@ textarea.input {
|
|||||||
transform: translateY(-40%);
|
transform: translateY(-40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-screen--channel-about .screen-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 8px 4px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.entrypoint-history-list {
|
.entrypoint-history-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -6184,12 +6192,13 @@ textarea.input {
|
|||||||
background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
|
background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
|
||||||
}
|
}
|
||||||
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
.dm-head-hex {
|
.dm-head-logo-wrap { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex: 0 0 auto; }
|
||||||
width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center;
|
.dm-head-logo {
|
||||||
font-weight: 700; font-size: 15px; color: #1a1205;
|
width: 36px;
|
||||||
background: linear-gradient(150deg, #F0B82E, #D49F22);
|
height: 36px;
|
||||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
object-fit: contain;
|
||||||
box-shadow: 0 0 14px rgba(240, 184, 46, 0.35);
|
display: block;
|
||||||
|
filter: drop-shadow(0 0 7px rgba(71, 196, 255, 0.38));
|
||||||
}
|
}
|
||||||
.dm-head-id { min-width: 0; display: grid; }
|
.dm-head-id { min-width: 0; display: grid; }
|
||||||
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -9100,6 +9109,66 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-header-connections-btn,
|
||||||
|
.chat-header-avatar-btn {
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-connections-btn:hover,
|
||||||
|
.chat-header-connections-btn:active,
|
||||||
|
.chat-header-connections-btn:focus,
|
||||||
|
.chat-header-connections-btn:focus-visible,
|
||||||
|
.chat-header-avatar-btn:hover,
|
||||||
|
.chat-header-avatar-btn:active,
|
||||||
|
.chat-header-avatar-btn:focus,
|
||||||
|
.chat-header-avatar-btn:focus-visible {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-connections-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(8, 17, 31, 0.65);
|
||||||
|
border: 1px solid rgba(97, 198, 255, 0.26);
|
||||||
|
box-shadow: 0 0 0 1px rgba(97, 198, 255, 0.08), 0 0 12px rgba(64, 172, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-connections-btn:hover,
|
||||||
|
.chat-header-connections-btn:focus-visible {
|
||||||
|
border-color: rgba(111, 208, 255, 0.38);
|
||||||
|
box-shadow: 0 0 0 1px rgba(111, 208, 255, 0.16), 0 0 16px rgba(64, 172, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-connections-logo {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-avatar-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-avatar-btn .chat-header-avatar-slot {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
/* Пузырям сообщений не нужна отдельная обводка: направление читается по фону и геометрии. */
|
/* Пузырям сообщений не нужна отдельная обводка: направление читается по фону и геометрии. */
|
||||||
.dm-chat-screen .bubble,
|
.dm-chat-screen .bubble,
|
||||||
.dm-chat-screen .bubble.in,
|
.dm-chat-screen .bubble.in,
|
||||||
|
|||||||
Reference in New Issue
Block a user