diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index ee5e0430..5bcdc551 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -30,6 +30,8 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_11 = 11; public static final int SCHEMA_VERSION_12 = 12; public static final int SCHEMA_VERSION_13 = 13; + public static final int SCHEMA_VERSION_14 = 14; + public static final int SCHEMA_VERSION_15 = 15; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -43,6 +45,8 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql"; public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql"; public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql"; + public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql"; + public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql"; private DatabaseInitializer() {} @@ -154,6 +158,14 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE); currentVersion = SCHEMA_VERSION_13; } + if (currentVersion < SCHEMA_VERSION_14) { + runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE); + currentVersion = SCHEMA_VERSION_14; + } + if (currentVersion < SCHEMA_VERSION_15) { + runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE); + currentVersion = SCHEMA_VERSION_15; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 2771a743..cb065341 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO { int deletedBlocks = deleteBlocksForChain(c, blockchainName); int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName); + rebuildStatsState(c); + c.commit(); return new CleanupResult( @@ -366,6 +368,100 @@ public final class BlockchainResyncCleanupDAO { """, blockchainName); } + private void rebuildStatsState(Connection c) throws SQLException { + try (PreparedStatement truncate = c.prepareStatement(""" + TRUNCATE TABLE user_stats_state, channel_stats_state + """)) { + truncate.executeUpdate(); + } + + try (PreparedStatement ps = c.prepareStatement(""" + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) + SELECT + u.login, + COALESCE(own.owned_public_channels_count, 0), + COALESCE(fu.following_users_count, 0), + COALESCE(fc.following_channels_count, 0), + COALESCE(cf.close_friends_count, 0), + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + FROM solana_user_pda_current u + LEFT JOIN ( + SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count + FROM channel_names_state + WHERE channel_type_code = 1 + GROUP BY owner_login + ) own ON LOWER(own.owner_login) = LOWER(u.login) + LEFT JOIN ( + SELECT login, COUNT(*)::INTEGER AS following_users_count + FROM connections_state + WHERE rel_type = 30 + AND to_block_number = 0 + GROUP BY login + ) fu ON LOWER(fu.login) = LOWER(u.login) + LEFT JOIN ( + SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count + FROM connections_state cs + JOIN channel_names_state cn + ON cn.owner_bch_name = cs.to_bch_name + AND cn.channel_root_block_number = cs.to_block_number + AND cn.channel_root_block_hash = cs.to_block_hash + WHERE cs.rel_type = 30 + AND cn.channel_type_code = 1 + GROUP BY cs.login + ) fc ON LOWER(fc.login) = LOWER(u.login) + LEFT JOIN ( + SELECT login, COUNT(*)::INTEGER AS close_friends_count + FROM connections_state + WHERE rel_type = 10 + GROUP BY login + ) cf ON LOWER(cf.login) = LOWER(u.login) + """)) { + ps.executeUpdate(); + } + + try (PreparedStatement ps = c.prepareStatement(""" + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) + SELECT + cn.owner_bch_name, + cn.channel_root_block_number, + cn.channel_root_block_hash, + cn.owner_login, + cn.channel_type_code, + COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + FROM channel_names_state cn + LEFT JOIN connections_state cs + ON cs.rel_type = 30 + AND cs.to_bch_name = cn.owner_bch_name + AND cs.to_block_number = cn.channel_root_block_number + AND cs.to_block_hash = cn.channel_root_block_hash + WHERE cn.channel_type_code = 1 + GROUP BY + cn.owner_bch_name, + cn.channel_root_block_number, + cn.channel_root_block_hash, + cn.owner_login, + cn.channel_type_code + """)) { + ps.executeUpdate(); + } + } + private int executeDelete(Connection c, String sql, String value) throws SQLException { try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, value); diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql new file mode 100644 index 00000000..177a3b13 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v14.sql @@ -0,0 +1,620 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_stats_state ( + login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0), + following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0), + following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0), + close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0), + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count + ON user_stats_state (following_users_count); + +CREATE TABLE IF NOT EXISTS channel_stats_state ( + owner_bch_name TEXT NOT NULL, + channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0), + channel_root_block_hash BYTEA NOT NULL, + owner_login TEXT NOT NULL, + channel_type_code INTEGER NOT NULL DEFAULT 1, + subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0), + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login + ON channel_stats_state (owner_login); + +CREATE OR REPLACE FUNCTION shine_user_stats_state_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + now_ms BIGINT; +BEGIN + now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); + + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + now_ms + ) + ON CONFLICT (login) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + now_ms BIGINT; + public_subscribers_count INTEGER; +BEGIN + now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); + + IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN + RETURN NEW; + END IF; + + IF NEW.channel_type_code = 1 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.owner_login, + 1, + 0, + 0, + 0, + now_ms + ) + ON CONFLICT (login) DO UPDATE SET + owned_public_channels_count = user_stats_state.owned_public_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + + SELECT COUNT(*)::INTEGER + INTO public_subscribers_count + FROM connections_state cs + JOIN channel_names_state cn + ON cn.owner_bch_name = cs.to_bch_name + AND cn.channel_root_block_number = cs.to_block_number + AND cn.channel_root_block_hash = cs.to_block_hash + WHERE cs.rel_type = 30 + AND cn.owner_bch_name = NEW.owner_bch_name + AND cn.channel_root_block_number = NEW.channel_root_block_number + AND cn.channel_root_block_hash = NEW.channel_root_block_hash + AND cn.channel_type_code = 1; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.owner_bch_name, + NEW.channel_root_block_number, + NEW.channel_root_block_hash, + NEW.owner_login, + NEW.channel_type_code, + COALESCE(public_subscribers_count, 0), + now_ms + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = EXCLUDED.subscribers_count, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + WITH pending AS ( + SELECT cs.login, COUNT(*)::INTEGER AS cnt + FROM connections_state cs + WHERE cs.rel_type = 30 + AND cs.to_bch_name = NEW.owner_bch_name + AND cs.to_block_number = NEW.channel_root_block_number + AND cs.to_block_hash = NEW.channel_root_block_hash + GROUP BY cs.login + ) + UPDATE user_stats_state us + SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt), + updated_at_ms = now_ms + FROM pending + WHERE us.login = pending.login; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + resolved_login TEXT; + positive_rel_type INTEGER; + existed_before BOOLEAN; + target_channel_type INTEGER; +BEGIN + IF NEW.msg_type <> 3 THEN + RETURN NEW; + END IF; + + resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name); + + IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN + IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN + RETURN NEW; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM connections_state + WHERE login = NEW.login + AND rel_type = NEW.msg_sub_type + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ) + INTO existed_before; + + IF NOT existed_before THEN + IF NEW.msg_sub_type = 10 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 1, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + close_friends_count = user_stats_state.close_friends_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF NEW.msg_sub_type = 30 THEN + IF NEW.to_block_number IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 1, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = user_stats_state.following_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF NEW.to_block_number = 0 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 1, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_users_count = user_stats_state.following_users_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + SELECT cn.channel_type_code + INTO target_channel_type + FROM channel_names_state cn + WHERE cn.owner_bch_name = NEW.to_bch_name + AND cn.channel_root_block_number = NEW.to_block_number + AND cn.channel_root_block_hash = NEW.to_block_hash + LIMIT 1; + + IF target_channel_type = 1 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 1, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = user_stats_state.following_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.to_bch_name, + NEW.to_block_number, + NEW.to_block_hash, + resolved_login, + target_channel_type, + 1, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = channel_stats_state.subscribers_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF target_channel_type IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 1, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = user_stats_state.following_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + END IF; + END IF; + END IF; + END IF; + + DELETE FROM connections_state + WHERE login = NEW.login + AND rel_type = NEW.msg_sub_type + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')); + + INSERT INTO connections_state ( + login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash + ) VALUES ( + NEW.login, + NEW.msg_sub_type, + resolved_login, + NEW.to_bch_name, + COALESCE(NEW.to_block_number, 0), + COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ); + + RETURN NEW; + END IF; + + positive_rel_type := CASE NEW.msg_sub_type + WHEN 11 THEN 10 + WHEN 21 THEN 20 + WHEN 31 THEN 30 + WHEN 41 THEN 40 + WHEN 51 THEN 50 + WHEN 53 THEN 52 + WHEN 55 THEN 54 + WHEN 61 THEN 60 + WHEN 71 THEN 70 + WHEN 75 THEN 74 + ELSE NULL + END; + + IF positive_rel_type IS NULL OR resolved_login IS NULL THEN + RETURN NEW; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM connections_state + WHERE login = NEW.login + AND rel_type = positive_rel_type + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ) + INTO existed_before; + + IF NOT existed_before THEN + RETURN NEW; + END IF; + + IF positive_rel_type = 10 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF positive_rel_type = 30 THEN + IF NEW.to_block_number IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF NEW.to_block_number = 0 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_users_count = GREATEST(0, user_stats_state.following_users_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + SELECT cn.channel_type_code + INTO target_channel_type + FROM channel_names_state cn + WHERE cn.owner_bch_name = NEW.to_bch_name + AND cn.channel_root_block_number = NEW.to_block_number + AND cn.channel_root_block_hash = NEW.to_block_hash + LIMIT 1; + + IF target_channel_type = 1 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.to_bch_name, + NEW.to_block_number, + NEW.to_block_hash, + resolved_login, + target_channel_type, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF target_channel_type IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + END IF; + END IF; + END IF; + + DELETE FROM connections_state + WHERE login = NEW.login + AND rel_type = positive_rel_type + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current; +CREATE TRIGGER trg_user_stats_state_ai +AFTER INSERT ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION shine_user_stats_state_ai(); + +DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state; +CREATE TRIGGER trg_channel_names_state_stats_ai +AFTER INSERT ON channel_names_state +FOR EACH ROW +EXECUTE FUNCTION shine_channel_names_state_stats_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks; +CREATE TRIGGER trg_blocks_connection_state_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_connection_state_ai(); + +TRUNCATE TABLE user_stats_state, channel_stats_state; + +INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms +) +SELECT + u.login, + COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count, + COALESCE(fu.following_users_count, 0) AS following_users_count, + COALESCE(fc.following_channels_count, 0) AS following_channels_count, + COALESCE(cf.close_friends_count, 0) AS close_friends_count, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms +FROM solana_user_pda_current u +LEFT JOIN ( + SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count + FROM channel_names_state + WHERE channel_type_code = 1 + GROUP BY owner_login +) own ON LOWER(own.owner_login) = LOWER(u.login) +LEFT JOIN ( + SELECT login, COUNT(*)::INTEGER AS following_users_count + FROM connections_state + WHERE rel_type = 30 + AND to_block_number = 0 + GROUP BY login +) fu ON LOWER(fu.login) = LOWER(u.login) +LEFT JOIN ( + SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count + FROM connections_state cs + JOIN channel_names_state cn + ON cn.owner_bch_name = cs.to_bch_name + AND cn.channel_root_block_number = cs.to_block_number + AND cn.channel_root_block_hash = cs.to_block_hash + WHERE cs.rel_type = 30 + AND cn.channel_type_code = 1 + GROUP BY cs.login +) fc ON LOWER(fc.login) = LOWER(u.login) +LEFT JOIN ( + SELECT login, COUNT(*)::INTEGER AS close_friends_count + FROM connections_state + WHERE rel_type = 10 + GROUP BY login +) cf ON LOWER(cf.login) = LOWER(u.login); + +INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms +) +SELECT + cn.owner_bch_name, + cn.channel_root_block_number, + cn.channel_root_block_hash, + cn.owner_login, + cn.channel_type_code, + COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms +FROM channel_names_state cn +LEFT JOIN connections_state cs + ON cs.rel_type = 30 + AND cs.to_bch_name = cn.owner_bch_name + AND cs.to_block_number = cn.channel_root_block_number + AND cs.to_block_hash = cn.channel_root_block_hash +WHERE cn.channel_type_code = 1 +GROUP BY + cn.owner_bch_name, + cn.channel_root_block_number, + cn.channel_root_block_hash, + cn.owner_login, + cn.channel_type_code; + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 14, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql new file mode 100644 index 00000000..341e2c22 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v15.sql @@ -0,0 +1,105 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + now_ms BIGINT; + public_subscribers_count INTEGER; +BEGIN + now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); + + IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN + RETURN NEW; + END IF; + + IF NEW.channel_type_code = 1 THEN + IF EXISTS ( + SELECT 1 + FROM solana_user_pda_current su + WHERE su.login = NEW.owner_login + ) THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.owner_login, + 1, + 0, + 0, + 0, + now_ms + ) + ON CONFLICT (login) DO UPDATE SET + owned_public_channels_count = user_stats_state.owned_public_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + END IF; + + SELECT COUNT(*)::INTEGER + INTO public_subscribers_count + FROM connections_state cs + JOIN channel_names_state cn + ON cn.owner_bch_name = cs.to_bch_name + AND cn.channel_root_block_number = cs.to_block_number + AND cn.channel_root_block_hash = cs.to_block_hash + WHERE cs.rel_type = 30 + AND cn.owner_bch_name = NEW.owner_bch_name + AND cn.channel_root_block_number = NEW.channel_root_block_number + AND cn.channel_root_block_hash = NEW.channel_root_block_hash + AND cn.channel_type_code = 1; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.owner_bch_name, + NEW.channel_root_block_number, + NEW.channel_root_block_hash, + NEW.owner_login, + NEW.channel_type_code, + COALESCE(public_subscribers_count, 0), + now_ms + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = EXCLUDED.subscribers_count, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + WITH pending AS ( + SELECT cs.login, COUNT(*)::INTEGER AS cnt + FROM connections_state cs + WHERE cs.rel_type = 30 + AND cs.to_bch_name = NEW.owner_bch_name + AND cs.to_block_number = NEW.channel_root_block_number + AND cs.to_block_hash = NEW.channel_root_block_hash + GROUP BY cs.login + ) + UPDATE user_stats_state us + SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt), + updated_at_ms = now_ms + FROM pending + WHERE us.login = pending.login; + END IF; + + RETURN NEW; +END; +$$; + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 15, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 102e88f3..b85cf244 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -291,6 +291,12 @@ AFTER TRUNCATE ON solana_user_pda_current FOR EACH STATEMENT EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); +DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current; +CREATE TRIGGER trg_user_stats_state_ai +AFTER INSERT ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION shine_user_stats_state_ai(); + CREATE TABLE IF NOT EXISTS solana_user_pda_history ( id BIGSERIAL PRIMARY KEY, tx_signature TEXT NOT NULL, @@ -605,6 +611,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner ON channel_names_state (owner_login, owner_bch_name); +CREATE TABLE IF NOT EXISTS user_stats_state ( + login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0), + following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0), + following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0), + close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0), + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count + ON user_stats_state (following_users_count); + +CREATE TABLE IF NOT EXISTS channel_stats_state ( + owner_bch_name TEXT NOT NULL, + channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0), + channel_root_block_hash BYTEA NOT NULL, + owner_login TEXT NOT NULL, + channel_type_code INTEGER NOT NULL DEFAULT 1, + subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0), + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login + ON channel_stats_state (owner_login); + CREATE TABLE IF NOT EXISTS chat200_state ( owner_login TEXT NOT NULL, owner_bch_name TEXT NOT NULL, @@ -921,6 +953,132 @@ BEGIN END; $$; +CREATE OR REPLACE FUNCTION shine_user_stats_state_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + now_ms BIGINT; +BEGIN + now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); + + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + now_ms + ) + ON CONFLICT (login) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + now_ms BIGINT; + public_subscribers_count INTEGER; +BEGIN + now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); + + IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN + RETURN NEW; + END IF; + + IF NEW.channel_type_code = 1 THEN + IF EXISTS ( + SELECT 1 + FROM solana_user_pda_current su + WHERE su.login = NEW.owner_login + ) THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.owner_login, + 1, + 0, + 0, + 0, + now_ms + ) + ON CONFLICT (login) DO UPDATE SET + owned_public_channels_count = user_stats_state.owned_public_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + END IF; + + SELECT COUNT(*)::INTEGER + INTO public_subscribers_count + FROM connections_state cs + JOIN channel_names_state cn + ON cn.owner_bch_name = cs.to_bch_name + AND cn.channel_root_block_number = cs.to_block_number + AND cn.channel_root_block_hash = cs.to_block_hash + WHERE cs.rel_type = 30 + AND cn.owner_bch_name = NEW.owner_bch_name + AND cn.channel_root_block_number = NEW.channel_root_block_number + AND cn.channel_root_block_hash = NEW.channel_root_block_hash + AND cn.channel_type_code = 1; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.owner_bch_name, + NEW.channel_root_block_number, + NEW.channel_root_block_hash, + NEW.owner_login, + NEW.channel_type_code, + COALESCE(public_subscribers_count, 0), + now_ms + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = EXCLUDED.subscribers_count, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + WITH pending AS ( + SELECT cs.login, COUNT(*)::INTEGER AS cnt + FROM connections_state cs + WHERE cs.rel_type = 30 + AND cs.to_bch_name = NEW.owner_bch_name + AND cs.to_block_number = NEW.channel_root_block_number + AND cs.to_block_hash = NEW.channel_root_block_hash + GROUP BY cs.login + ) + UPDATE user_stats_state us + SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt), + updated_at_ms = now_ms + FROM pending + WHERE us.login = pending.login; + END IF; + + RETURN NEW; +END; +$$; + CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() RETURNS TRIGGER LANGUAGE plpgsql @@ -999,6 +1157,8 @@ AS $$ DECLARE resolved_login TEXT; positive_rel_type INTEGER; + existed_before BOOLEAN; + target_channel_type INTEGER; BEGIN IF NEW.msg_type <> 3 THEN RETURN NEW; @@ -1011,10 +1171,159 @@ BEGIN RETURN NEW; END IF; + SELECT EXISTS ( + SELECT 1 + FROM connections_state + WHERE login = NEW.login + AND rel_type = NEW.msg_sub_type + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ) + INTO existed_before; + + IF NOT existed_before THEN + IF NEW.msg_sub_type = 10 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 1, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + close_friends_count = user_stats_state.close_friends_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF NEW.msg_sub_type = 30 THEN + IF NEW.to_block_number IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 1, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = user_stats_state.following_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF NEW.to_block_number = 0 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 1, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_users_count = user_stats_state.following_users_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + SELECT cn.channel_type_code + INTO target_channel_type + FROM channel_names_state cn + WHERE cn.owner_bch_name = NEW.to_bch_name + AND cn.channel_root_block_number = NEW.to_block_number + AND cn.channel_root_block_hash = NEW.to_block_hash + LIMIT 1; + + IF target_channel_type = 1 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 1, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = user_stats_state.following_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.to_bch_name, + NEW.to_block_number, + NEW.to_block_hash, + resolved_login, + target_channel_type, + 1, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = channel_stats_state.subscribers_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF target_channel_type IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 1, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = user_stats_state.following_channels_count + 1, + updated_at_ms = EXCLUDED.updated_at_ms; + END IF; + END IF; + END IF; + END IF; + DELETE FROM connections_state WHERE login = NEW.login AND rel_type = NEW.msg_sub_type - AND to_login = resolved_login; + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')); INSERT INTO connections_state ( login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash @@ -1048,10 +1357,161 @@ BEGIN RETURN NEW; END IF; + SELECT EXISTS ( + SELECT 1 + FROM connections_state + WHERE login = NEW.login + AND rel_type = positive_rel_type + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ) + INTO existed_before; + + IF NOT existed_before THEN + RETURN NEW; + END IF; + + IF positive_rel_type = 10 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF positive_rel_type = 30 THEN + IF NEW.to_block_number IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF NEW.to_block_number = 0 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_users_count = GREATEST(0, user_stats_state.following_users_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSE + SELECT cn.channel_type_code + INTO target_channel_type + FROM channel_names_state cn + WHERE cn.owner_bch_name = NEW.to_bch_name + AND cn.channel_root_block_number = NEW.to_block_number + AND cn.channel_root_block_hash = NEW.to_block_hash + LIMIT 1; + + IF target_channel_type = 1 THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + + INSERT INTO channel_stats_state ( + owner_bch_name, + channel_root_block_number, + channel_root_block_hash, + owner_login, + channel_type_code, + subscribers_count, + updated_at_ms + ) VALUES ( + NEW.to_bch_name, + NEW.to_block_number, + NEW.to_block_hash, + resolved_login, + target_channel_type, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET + owner_login = EXCLUDED.owner_login, + channel_type_code = EXCLUDED.channel_type_code, + subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + ELSIF target_channel_type IS NULL THEN + INSERT INTO user_stats_state ( + login, + owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count, + updated_at_ms + ) VALUES ( + NEW.login, + 0, + 0, + 0, + 0, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + ) + ON CONFLICT (login) DO UPDATE SET + following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1), + updated_at_ms = EXCLUDED.updated_at_ms; + END IF; + END IF; + END IF; + DELETE FROM connections_state WHERE login = NEW.login AND rel_type = positive_rel_type - AND to_login = resolved_login; + AND to_login = resolved_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = COALESCE(NEW.to_block_number, 0) + AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')); RETURN NEW; END; @@ -1231,4 +1691,10 @@ AFTER INSERT ON blocks FOR EACH ROW EXECUTE FUNCTION shine_blocks_edit_apply_ai(); +DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state; +CREATE TRIGGER trg_channel_names_state_stats_ai +AFTER INSERT ON channel_names_state +FOR EACH ROW +EXECUTE FUNCTION shine_channel_names_state_stats_ai(); + COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index c3b063cd..23bc7042 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -16,6 +16,8 @@ import utils.blockchain.BlockchainNameUtil; import blockchain.body.CreateChannelBody; import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.util.ArrayList; import java.util.List; @@ -65,6 +67,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs); channel.setChannelTypeCode(meta.channelTypeCode); channel.setChannelTypeVersion(meta.channelTypeVersion); + channel.setSubscribersCount(loadSubscribersCount(c, ownerBch, lineCode, meta.channelTypeCode)); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); rootRef.setBlockNumber(lineCode); rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash()); @@ -180,4 +183,28 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); } } + + private static int loadSubscribersCount(Connection c, String ownerBch, int rootNumber, int channelTypeCode) { + if (channelTypeCode != (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) { + return 0; + } + String sql = """ + SELECT subscribers_count + FROM channel_stats_state + WHERE owner_bch_name = ? + AND channel_root_block_number = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBch); + ps.setInt(2, rootNumber); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return 0; + return Math.max(0, rs.getInt("subscribers_count")); + } + } catch (Exception e) { + log.warn("GetChannelMessages: не удалось загрузить subscribers_count для {}#{}", ownerBch, rootNumber, e); + return 0; + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 7d5f0a92..811d4c6c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -32,6 +32,7 @@ public class Net_GetChannelMessages_Response extends Net_Response { private Long metaUpdatedAtMs; private Integer channelTypeCode; private Integer channelTypeVersion; + private Integer subscribersCount; private BlockRef channelRoot; public String getOwnerLogin() { return ownerLogin; } @@ -67,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response { public Integer getChannelTypeVersion() { return channelTypeVersion; } public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; } + public Integer getSubscribersCount() { return subscribersCount; } + public void setSubscribersCount(Integer subscribersCount) { this.subscribersCount = subscribersCount; } + public BlockRef getChannelRoot() { return channelRoot; } public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 961faa65..1298d603 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Re import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.BlockchainStateDAO; +import shine.db.DbController; import shine.db.dao.CurrentUsersDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.util.Arrays; public class Net_GetUser_Handler implements JsonMessageHandler { @@ -63,6 +67,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler { resp.setSolanaKey(u.getSolanaKey()); resp.setBlockchainKey(u.getBlockchainKey()); resp.setClientKey(u.getClientKey()); + loadUserStats(resp, u.getLogin()); // Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна, // автоматически восстанавливаем её для существующего пользователя. @@ -125,4 +130,35 @@ public class Net_GetUser_Handler implements JsonMessageHandler { } return new String(out); } + + private static void loadUserStats(Net_GetUser_Response resp, String login) { + resp.setOwnedPublicChannelsCount(0); + resp.setFollowingUsersCount(0); + resp.setFollowingChannelsCount(0); + resp.setCloseFriendsCount(0); + String sql = """ + SELECT owned_public_channels_count, + following_users_count, + following_channels_count, + close_friends_count + FROM user_stats_state + WHERE login = ? + LIMIT 1 + """; + try (Connection c = DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return; + } + resp.setOwnedPublicChannelsCount(rs.getInt("owned_public_channels_count")); + resp.setFollowingUsersCount(rs.getInt("following_users_count")); + resp.setFollowingChannelsCount(rs.getInt("following_channels_count")); + resp.setCloseFriendsCount(rs.getInt("close_friends_count")); + } + } catch (Exception e) { + log.warn("GetUser: не удалось загрузить статистику для login={}", login, e); + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java index 0037ed2a..c9c0da10 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java @@ -43,6 +43,10 @@ public class Net_GetUser_Response extends Net_Response { private String serverLastGlobalHash; private Long serverBlockchainSizeBytes; private Long serverBlockchainSizeLimitBytes; + private Integer ownedPublicChannelsCount; + private Integer followingUsersCount; + private Integer followingChannelsCount; + private Integer closeFriendsCount; public Boolean getExists() { return exists; } public void setExists(Boolean exists) { this.exists = exists; } @@ -74,4 +78,16 @@ public class Net_GetUser_Response extends Net_Response { public Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; } public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = serverBlockchainSizeLimitBytes; } + public Integer getOwnedPublicChannelsCount() { return ownedPublicChannelsCount; } + public void setOwnedPublicChannelsCount(Integer ownedPublicChannelsCount) { this.ownedPublicChannelsCount = ownedPublicChannelsCount; } + + public Integer getFollowingUsersCount() { return followingUsersCount; } + public void setFollowingUsersCount(Integer followingUsersCount) { this.followingUsersCount = followingUsersCount; } + + public Integer getFollowingChannelsCount() { return followingChannelsCount; } + public void setFollowingChannelsCount(Integer followingChannelsCount) { this.followingChannelsCount = followingChannelsCount; } + + public Integer getCloseFriendsCount() { return closeFriendsCount; } + public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; } + } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index 68d61890..1d85001a 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -5,6 +5,7 @@ import blockchain.body.ConnectionBody; import blockchain.body.CreateChannelBody; import blockchain.body.HeaderBody; import blockchain.body.TextBody; +import shine.db.DbController; import test.it.blockchain.AddBlockSender; import test.it.blockchain.ChainState; import test.it.utils.TestConfig; @@ -13,6 +14,10 @@ import test.it.utils.log.TestResult; import test.it.utils.ws.WsSession; import java.time.Duration; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import static org.junit.jupiter.api.Assertions.*; @@ -117,6 +122,27 @@ public class IT_03_AddBlock_NoAuth { st1.registerTextChannelRoot(newsRootBlock, newsRootHash); } + // CREATE_CHANNEL "Updates" — второй публичный канал того же владельца + int updatesRootBlock; + byte[] updatesRootHash; + { + var ln = st1.nextLineByType(ChainState.TYPE_TECH); + sender1.send(new CreateChannelBody( + 0, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "Updates", + "", + CreateChannelBody.CHANNEL_TYPE_PUBLIC, + CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT + ), t); + + updatesRootBlock = st1.lastBlockNumber(); + updatesRootHash = st1.getHash32(updatesRootBlock); + assertNotNull(updatesRootHash); + + st1.registerTextChannelRoot(updatesRootBlock, updatesRootHash); + } + // POST #0 в канал "News" int newsPost0Block; byte[] newsPost0Hash; @@ -188,7 +214,21 @@ public class IT_03_AddBlock_NoAuth { bch1, newsRootBlock, newsRootHash, "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); - // 3) FRIEND взаимно (на HEADER) + // 3) U2 подписался на второй канал U1 "Updates" + sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, + bch1, updatesRootBlock, updatesRootHash, + "U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t); + + assertEquals(2, countConnectionsByOwner(u2, u1), + "U2 должен иметь две отдельные записи подписки на каналы U1"); + assertEquals(2, countFollowingChannels(u2), + "following_channels_count должен учитывать два разных канала одного владельца"); + assertEquals(1, countSubscribers(bch1, newsRootBlock, newsRootHash), + "У канала News должен быть 1 подписчик"); + assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash), + "У канала Updates должен быть 1 подписчик"); + + // 4) FRIEND взаимно (на HEADER) sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: FRIEND", t); @@ -197,7 +237,7 @@ public class IT_03_AddBlock_NoAuth { bch1, u1HeaderBlock, u1HeaderHash, "U2 -> U1: FRIEND", t); - // 4) CONTACT несколько + // 5) CONTACT несколько sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: CONTACT", t); @@ -236,7 +276,21 @@ public class IT_03_AddBlock_NoAuth { bch3, u3HeaderBlock, u3HeaderHash, "U1 -> U3: CONTACT", t); - // 5) U1 убирает U2 из контактов (UNCONTACT) + // 6) U2 отписывается только от News + sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW, + bch1, newsRootBlock, newsRootHash, + "U2 unfollows U1 channel 'News'", t); + + assertEquals(1, countConnectionsByOwner(u2, u1), + "После отписки от одного канала должна остаться одна запись подписки"); + assertEquals(1, countFollowingChannels(u2), + "following_channels_count должен уменьшиться ровно на 1"); + assertEquals(0, countSubscribers(bch1, newsRootBlock, newsRootHash), + "После отписки от News у канала должен остаться 0 подписчиков"); + assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash), + "Подписка на Updates должна остаться"); + + // 7) U1 убирает U2 из контактов (UNCONTACT) sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: UNCONTACT", t); @@ -288,4 +342,58 @@ public class IT_03_AddBlock_NoAuth { toBlockHash32 ), timeout); } + + private static int countConnectionsByOwner(String login, String ownerLogin) { + return queryInt(""" + SELECT COUNT(*) + FROM connections_state + WHERE login = ? + AND rel_type = 30 + AND to_login = ? + """, login, ownerLogin); + } + + private static int countFollowingChannels(String login) { + return queryInt(""" + SELECT following_channels_count + FROM user_stats_state + WHERE login = ? + """, login); + } + + private static int countSubscribers(String ownerBlockchainName, int rootBlockNumber, byte[] rootBlockHash) { + return queryInt(""" + SELECT COALESCE(subscribers_count, 0) + FROM channel_stats_state + WHERE owner_bch_name = ? + AND channel_root_block_number = ? + AND channel_root_block_hash = ? + """, ownerBlockchainName, rootBlockNumber, rootBlockHash); + } + + private static int queryInt(String sql, Object... params) { + try (Connection c = DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + for (int i = 0; i < params.length; i++) { + Object p = params[i]; + if (p instanceof String s) { + ps.setString(i + 1, s); + } else if (p instanceof Integer n) { + ps.setInt(i + 1, n); + } else if (p instanceof byte[] bytes) { + ps.setBytes(i + 1, bytes); + } else { + throw new IllegalArgumentException("Unsupported SQL param type: " + (p == null ? "null" : p.getClass())); + } + } + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new IllegalStateException("Query returned no rows: " + sql); + } + return rs.getInt(1); + } + } catch (SQLException e) { + throw new RuntimeException("DB query failed: " + sql, e); + } + } } diff --git a/docs/API/01_User_Registration_API.md b/docs/API/01_User_Registration_API.md index ba411334..25ccd9ce 100644 --- a/docs/API/01_User_Registration_API.md +++ b/docs/API/01_User_Registration_API.md @@ -57,7 +57,11 @@ "serverLastGlobalNumber": 128, "serverLastGlobalHash": "4f...ab", "serverBlockchainSizeBytes": 45212, - "serverBlockchainSizeLimitBytes": 100000 + "serverBlockchainSizeLimitBytes": 100000, + "ownedPublicChannelsCount": 3, + "followingUsersCount": 18, + "followingChannelsCount": 27, + "closeFriendsCount": 4 } } ``` @@ -68,6 +72,10 @@ - `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа); - `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах; - `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах; +- `ownedPublicChannelsCount` — количество публичных каналов, владельцем которых является пользователь; +- `followingUsersCount` — количество пользователей, на которых подписан пользователь; +- `followingChannelsCount` — количество публичных каналов, на которые подписан пользователь; +- `closeFriendsCount` — количество близких друзей пользователя. ### Успешный ответ: пользователя нет diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index b2e24c69..7cd54351 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -158,6 +158,7 @@ "avaSha256": "0123...", "avaSize": 248193, "metaUpdatedAtMs": 1760000000000, + "subscribersCount": 128, "channelRoot": { "blockNumber": 123, "blockHash": "..." } }, "metaEvents": [ diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 33b7595c..9f1f6577 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,18 @@ # История изменений документации блокчейна +## 2026-08-25 19:45:18 +0400 +- Базовый коммит-ориентир: `3a58519`. +- Добавлены runtime-агрегаты статистики: + - `user_stats_state` хранит `owned_public_channels_count`, `following_users_count`, `following_channels_count`, `close_friends_count`; + - `channel_stats_state` хранит `subscribers_count` для публичных каналов. +- Обновление статистики выполняется атомарно вместе с записью блока в той же SQL-транзакции `AddBlock`. +- После полного resync статистика пересобирается из текущих `connections_state` и `channel_names_state`, чтобы старые данные продолжали работать. + +## 2026-08-25 19:59:00 +0400 +- Базовый коммит-ориентир: `working tree`. +- Триггер `channel_names_state` для статистики сделан устойчивым к старым данным, где канал уже есть, а владельца ещё нет в `solana_user_pda_current`. +- Добавлена совместимая миграция `v15`, чтобы существующие базы перестали падать на старых каналах при старте сервера. + ## 2026-08-12 13:00:00 +0400 - Базовый коммит-ориентир: `working tree`. - Канонический текстовый формат служебных тегов сокращён: diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 17958027..516e05c4 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -42,4 +42,6 @@ ## Обязательное сопровождение - При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений. - Обычный `AddBlock` сейчас пишет через `.tmp_bch`, `.write_check` и `.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery. +- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync. +- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA. - Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. diff --git a/shine-UI/assets/SHiNE_connections_blue.svg b/shine-UI/assets/SHiNE_connections_blue.svg new file mode 100644 index 00000000..65808151 --- /dev/null +++ b/shine-UI/assets/SHiNE_connections_blue.svg @@ -0,0 +1,23 @@ + + SHiNE connections + Blue six-part connections symbol on a transparent background + + + + + + + + + + + + + + + + + + ( + String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch + && Number(row?.channel?.channelRoot?.blockNumber) === rootNo + && normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash + )) || null; +} + +function buildChannelLink(route) { + if (!route) return ''; + const url = new URL(window.location.href); + url.pathname = `/${String(route).replace(/^\/+/, '')}`; + url.hash = ''; + return url.toString(); +} + +function statsText(value) { + return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0'; +} + +export function render({ navigate, route, chrome }) { + const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim(); + const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber); + const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash); + const channelRoute = makeShineChannelRootRoute({ + ownerBlockchainName, + channelRootBlockNumber, + channelRootBlockHash, + }); + + const screen = document.createElement('section'); + screen.className = 'stack channels-screen channels-screen--channel-about'; + + const topbar = renderHeader({ + title: 'О канале', + leftAction: { + label: '←', + onClick: () => { + if (window.history.length > 1) { + navigateBack(); + return; + } + if (channelRoute) navigate(channelRoute); + }, + ariaLabel: 'Назад', + title: 'Назад', + }, + }); + chrome?.setTopbar(topbar); + + const card = document.createElement('div'); + card.className = 'card stack channel-about-card'; + card.innerHTML = ` +
+
Загрузка данных канала…
+
+ `; + + const footer = document.createElement('div'); + footer.className = 'meta-muted screen-footer'; + footer.textContent = 'О канале (channel-about-view)'; + + screen.append(card, footer); + + const renderContent = (channel) => { + const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); + const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim(); + const description = String(channel?.channelDescription || channel?.description || '').trim(); + const subscribersCount = Number(channel?.subscribersCount || 0); + const aboutRoute = makeShineChannelRootRoute({ + ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, + channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber, + channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash, + }); + const channelLinkRoute = makeShineChannelShortRoute({ + ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, + channelName: channel?.channelName || '', + }); + const channelLink = buildChannelLink(channelLinkRoute); + const changedAtMs = Number(channel?.metaUpdatedAtMs || 0); + const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—'; + const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен'; + + const content = card.querySelector('#channel-about-content'); + if (!content) return; + content.innerHTML = ` +
+ +
+
+ Дата${escapeHtml(changedAtLabel)} + Владелец${escapeHtml(ownerName)} + Подписчиков${escapeHtml(statsText(subscribersCount))} + Системное имя${escapeHtml(String(channel?.channelName || '').trim() || 'channel')} + Название${escapeHtml(cleanName)} + Описание${escapeHtml(description || 'Описание не задано.')} + Аватар${escapeHtml(avatarState)} + Ссылка${escapeHtml(channelLink)} +
+
+ + +
+ `; + + content.querySelector('#channel-about-open')?.addEventListener('click', () => { + if (!channelLinkRoute) return; + navigate(channelLinkRoute); + }); + content.querySelector('#channel-about-copy')?.addEventListener('click', async () => { + if (!channelLink) return; + try { + await navigator.clipboard.writeText(channelLink); + showToast('Ссылка скопирована'); + } catch (error) { + showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' }); + } + }); + }; + + const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash); + if (cached?.channel) { + renderContent({ + ...cached.channel, + subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0, + }); + return screen; + } + + void (async () => { + try { + const payload = await authService.getChannelMessages({ + ownerBlockchainName, + channelRootBlockNumber, + channelRootBlockHash, + }, 1, 'asc', String(state.session.login || '').trim()); + renderContent(payload?.channel || {}); + } catch (error) { + const content = card.querySelector('#channel-about-content'); + if (content) { + content.innerHTML = ` +
Не удалось загрузить данные канала.
+
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
+ `; + } + } + })(); + + return screen; +} diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 54c6c8bd..363c8ed6 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -30,6 +30,7 @@ import { extractLoginFromBlockchainName, makeProfileRoute, makeShineMessageRoute, + makeShineChannelAboutRoute, } from '../services/shine-routes.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -2550,13 +2551,12 @@ export function render({ navigate, route, chrome }) { channelHeaderButton.disabled = false; channelHeaderButton.onclick = (event) => { animatePress(event.currentTarget); - openAboutChannelModal(apiData.channel, { - canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel), - onEdit: () => openEditChannelModal({ - channel: apiData.channel, - onSave: onEditChannelMeta, - }), + const aboutRoute = makeShineChannelAboutRoute({ + ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', + channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '', + channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '', }); + if (aboutRoute) navigate(aboutRoute); }; } if (channelEntrypointButton) { diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index b48946b7..ce0cf858 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -12,6 +12,7 @@ import { writeChannelNotificationsState, } from '../services/channels-ux.js'; import { makeShineChannelRoute } from '../services/shine-routes.js'; +import { makeShineChannelShortRoute } from '../services/shine-routes.js'; import { renderAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -64,10 +65,9 @@ function buildChannelRouteFromSummary(summary, fallbackId) { const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim(); const ownerLogin = String(summary?.channel?.ownerLogin || '').trim(); const channelName = String(summary?.channel?.channelName || '').trim(); - return makeShineChannelRoute({ - ownerLogin, + return makeShineChannelShortRoute({ ownerBlockchainName: ownerBch, - channelName: channelName || fallbackId, + channelName: channelName || fallbackId || ownerLogin, }); } diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index c0c74328..d9017eba 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,6 +1,7 @@ import { renderHeader } from '../components/header.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; +import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, @@ -30,11 +31,12 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec import { showToast } from '../services/channels-ux.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; -function createChatHeaderParts(login) { +function createChatHeaderParts(login, navigate) { const cleanLogin = String(login || '').trim() || 'unknown'; const avatarSlot = document.createElement('span'); @@ -47,6 +49,13 @@ function createChatHeaderParts(login) { }); avatarSlot.append(initialAvatar); + const avatarButton = document.createElement('button'); + avatarButton.type = 'button'; + avatarButton.className = 'chat-header-avatar-btn'; + avatarButton.title = `Профиль ${cleanLogin}`; + avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`); + avatarButton.append(avatarSlot); + const loginEl = document.createElement('span'); loginEl.className = 'chat-header-login'; loginEl.setAttribute('role', 'heading'); @@ -75,7 +84,23 @@ function createChatHeaderParts(login) { }) .catch(() => {}); - return { centerNode: loginEl, avatarSlot }; + const connectionsButton = document.createElement('button'); + connectionsButton.type = 'button'; + connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn'; + connectionsButton.title = `Связи ${cleanLogin}`; + connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`); + connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' })); + connectionsButton.addEventListener('click', () => { + if (!cleanLogin || cleanLogin === 'unknown') return; + navigate(makeProfileLinksRoute(cleanLogin)); + }); + + avatarButton.addEventListener('click', () => { + if (!cleanLogin || cleanLogin === 'unknown') return; + navigate(makeProfileRoute(cleanLogin)); + }); + + return { centerNode: loginEl, avatarButton, connectionsButton }; } function truncatePreviewText(value, maxLen = 72) { @@ -293,7 +318,6 @@ function openChatActionsMenu({ anchorY = 0, onCall, onVideoCall, - onInstantVideoCall, onClearHistory, onDeleteChat, }) { @@ -305,8 +329,7 @@ function openChatActionsMenu({
@@ -358,10 +381,6 @@ function openChatActionsMenu({ close(); if (typeof onVideoCall === 'function') await onVideoCall(); }); - root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => { - close(); - if (typeof onInstantVideoCall === 'function') await onInstantVideoCall(); - }); root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => { close(); if (typeof onClearHistory === 'function') await onClearHistory(); @@ -993,7 +1012,7 @@ export function render({ navigate, route, chrome }) { const log = document.createElement('div'); log.className = 'messages-log dm-messages-log'; - const chatHeaderParts = createChatHeaderParts(chatId); + const chatHeaderParts = createChatHeaderParts(chatId, navigate); const chatHeader = renderHeader({ centerNode: chatHeaderParts.centerNode, leftAction: { label: '←', onClick: () => navigate('messages-list') }, @@ -1016,7 +1035,6 @@ export function render({ navigate, route, chrome }) { anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0), onCall: () => handleStartCall('audio'), onVideoCall: () => handleStartCall('video'), - onInstantVideoCall: () => handleStartCall('instant_video'), onClearHistory: async () => { openChatConfirmModal({ title: 'Очистить историю?', @@ -1064,7 +1082,8 @@ export function render({ navigate, route, chrome }) { }, ], }); - chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot); + const chatHeaderLeft = chatHeader.querySelector('.header-left'); + chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton); chrome?.setTopbar(chatHeader); if (!isKnownContact) { diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index d52d2916..9aa41db1 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -8,6 +8,7 @@ import { } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; +import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -167,15 +168,11 @@ function compareChatRows(a, b) { export function render({ navigate, chrome }) { const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-list-screen'; - const login = String(state.session.login || '').trim(); const head = document.createElement('header'); head.className = 'dm-head'; head.innerHTML = `
-
${(login[0] || 'A').toUpperCase()}
-
- -
+

Чаты

@@ -191,6 +188,9 @@ export function render({ navigate, chrome }) {
`; + head.querySelector('.dm-head-logo-wrap')?.append( + createShineConnectionsLogo({ className: 'dm-head-logo' }), + ); const menuButton = head.querySelector('.dm-head-menu-btn'); menuButton?.append(createOverflowDots()); diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 231c84bc..8ba70adb 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -1,5 +1,5 @@ import { profile } from '../mock-data.js'; -import { state } from '../state.js'; +import { authService, state } from '../state.js'; import { PROFILE_GENDER_FEMALE, PROFILE_GENDER_MALE, @@ -223,6 +223,12 @@ export function render({ navigate, chrome }) { let currentToggles = []; let currentGender = 'unknown'; let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; + let currentStats = { + ownedPublicChannelsCount: 0, + followingUsersCount: 0, + followingChannelsCount: 0, + closeFriendsCount: 0, + }; function syncIdentity() { if (!identityEl) return; @@ -269,6 +275,21 @@ export function render({ navigate, chrome }) { updateToggleButton(shineBtn, 'Сияющий', shine.enabled); } + function renderStats() { + const stats = [ + { label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount }, + { label: 'Подписки на пользователей', value: currentStats.followingUsersCount }, + { label: 'Подписки на каналы', value: currentStats.followingChannelsCount }, + { label: 'Близкие друзья', value: currentStats.closeFriendsCount }, + ]; + stats.forEach((stat) => { + const row = document.createElement('div'); + row.className = 'card profile-param-item row'; + row.innerHTML = `
${escapeHtml(stat.label)}: ${escapeHtml(String(Number(stat.value || 0)))}
`; + listWrap.append(row); + }); + } + officialBtn?.classList.add('profile-badge-trigger'); shineBtn?.classList.add('profile-badge-trigger'); officialBtn?.addEventListener('click', () => { @@ -286,6 +307,7 @@ export function render({ navigate, chrome }) { function renderFields(fields) { listWrap.innerHTML = ''; + renderStats(); fields.forEach((field) => { const row = document.createElement('div'); row.className = 'card profile-param-item row'; @@ -317,6 +339,12 @@ export function render({ navigate, chrome }) { ]; currentGender = 'unknown'; currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; + currentStats = { + ownedPublicChannelsCount: 0, + followingUsersCount: 0, + followingChannelsCount: 0, + closeFriendsCount: 0, + }; syncIdentity(); updateAvatarUi(); updateTogglesUi(); @@ -325,11 +353,20 @@ export function render({ navigate, chrome }) { } try { - const snapshot = await loadProfileSnapshot(login); + const [snapshot, user] = await Promise.all([ + loadProfileSnapshot(login), + authService.getUser(login).catch(() => ({})), + ]); currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : []; currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : []; currentGender = snapshot.gender || 'unknown'; currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; + currentStats = { + ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0), + followingUsersCount: Number(user?.followingUsersCount || 0), + followingChannelsCount: Number(user?.followingChannelsCount || 0), + closeFriendsCount: Number(user?.closeFriendsCount || 0), + }; syncIdentity(); updateAvatarUi(); updateTogglesUi(); diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index f06cf35f..7323605a 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -26,6 +26,7 @@ const PRETTY_PATHS = new Map([ ['add-channel-view', 'channels/new'], ['add-personal-public-chat-view', 'channels/new-public-chat'], ['channel-view', 'channel'], + ['channel-about-view', 'channel/about'], ['channel-thread-view', 'thread'], ['network-view', 'network'], ['notifications-view', 'notifications'], @@ -52,6 +53,10 @@ const PRETTY_PATHS = new Map([ ['remote-addblock-session-view', 'remote-addblock-session'], ]); +function looksLikeBlockchainName(value) { + return /^.+-\d+$/.test(String(value || '').trim()); +} + export const PRE_AUTH_PAGES = [ 'start-view', 'entry-settings-view', @@ -162,6 +167,34 @@ export function parseRouteFromPath(pathname = '') { } } + if (segments.length >= 2 && looksLikeBlockchainName(segments[0])) { + const ownerBlockchainName = decodePart(segments[0]); + const channelName = decodePart(segments[1] || ''); + const sub = decodePart(segments[2] || '').toLowerCase(); + if (ownerBlockchainName && channelName) { + if (sub === 'about') { + return { + pageId: 'channel-about-view', + params: { + ownerBlockchainName, + channelRootBlockNumber: '', + channelRootBlockHash: '', + channelId: '', + channelName, + }, + }; + } + return { + pageId: 'channel-view', + params: { + ownerBlockchainName, + channelName, + channelId: '', + }, + }; + } + } + if (pageId === 'chat' || pageId === 'chat-view') { return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } }; } @@ -225,6 +258,17 @@ export function parseRouteFromPath(pathname = '') { } if (pageId === 'channel') { + if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') { + return { + pageId: 'channel-about-view', + params: { + ownerBlockchainName: decodePart(segments[1]), + channelRootBlockNumber: segments[2] || '', + channelRootBlockHash: segments[3] || '', + channelId: '', + }, + }; + } if (segments.length >= 4) { return { pageId: 'channel-view', @@ -408,7 +452,7 @@ export function resolveToolbarActive(pageId) { pageId === 'solana-users-init-view' ) return 'profile-view'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; - if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; + if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; if (pageId === 'user') return 'messages-list'; return 'profile-view'; } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index a8eb7afd..b7b77bbe 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -153,7 +153,7 @@ function getCallTitleText(mode) { return 'Видеозвонок'; } if (normalizeCallMode(mode) === CALL_MODE_VIDEO) { - return 'Звонок с поддержкой видео'; + return 'Видеозвонок'; } return 'Звонок'; } @@ -164,7 +164,7 @@ function getIncomingCallStatusText(peerLogin, mode) { return `Входящий видеозвонок от ${name}`; } if (isVideoCallMode(mode)) { - return `Вам звонит ${name} (звонок с поддержкой видео)`; + return `Входящий видеозвонок от ${name}`; } return `Вам звонит ${name}`; } diff --git a/shine-UI/js/services/shine-routes.js b/shine-UI/js/services/shine-routes.js index 7cc605cb..27df45d2 100644 --- a/shine-UI/js/services/shine-routes.js +++ b/shine-UI/js/services/shine-routes.js @@ -51,6 +51,26 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = ' return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; } +export function makeShineChannelRootRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) { + const ownerBch = String(ownerBlockchainName || '').trim(); + const rootNo = String(channelRootBlockNumber || '').trim(); + const rootHash = String(channelRootBlockHash || '').trim(); + if (!ownerBch || !rootNo || !rootHash) return ''; + return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(rootNo)}/${encodeRoutePart(rootHash)}`; +} + +export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) { + const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash }); + return base ? `${base}/about` : ''; +} + +export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) { + const ownerBch = String(ownerBlockchainName || '').trim(); + const chName = String(channelName || '').trim(); + if (!ownerBch || !chName) return ''; + return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; +} + export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) { const msgBch = String(messageBlockchainName || '').trim(); const msgNo = String(messageBlockNumber || '').trim(); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 33590d4a..8b052f45 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -5927,6 +5927,14 @@ textarea.input { transform: translateY(-40%); } +.channels-screen--channel-about .screen-footer { + margin-top: auto; + padding: 8px 4px 0; + text-align: center; + font-size: 12px; + letter-spacing: 0.02em; +} + .entrypoint-history-list { display: grid; gap: 10px; @@ -6184,12 +6192,13 @@ textarea.input { background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0)); } .dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; } -.dm-head-hex { - width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; - font-weight: 700; font-size: 15px; color: #1a1205; - background: linear-gradient(150deg, #F0B82E, #D49F22); - clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); - box-shadow: 0 0 14px rgba(240, 184, 46, 0.35); +.dm-head-logo-wrap { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex: 0 0 auto; } +.dm-head-logo { + width: 36px; + height: 36px; + object-fit: contain; + display: block; + filter: drop-shadow(0 0 7px rgba(71, 196, 255, 0.38)); } .dm-head-id { min-width: 0; display: grid; } .dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -9100,6 +9109,66 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn { gap: 0; } +.chat-header-connections-btn, +.chat-header-avatar-btn { + padding: 0; + min-width: 0; + min-height: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; +} + +.chat-header-connections-btn:hover, +.chat-header-connections-btn:active, +.chat-header-connections-btn:focus, +.chat-header-connections-btn:focus-visible, +.chat-header-avatar-btn:hover, +.chat-header-avatar-btn:active, +.chat-header-avatar-btn:focus, +.chat-header-avatar-btn:focus-visible { + border: 0; + background: transparent; + box-shadow: none; + transform: none; +} + +.chat-header-connections-btn { + width: 38px; + height: 38px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(8, 17, 31, 0.65); + border: 1px solid rgba(97, 198, 255, 0.26); + box-shadow: 0 0 0 1px rgba(97, 198, 255, 0.08), 0 0 12px rgba(64, 172, 255, 0.18); +} + +.chat-header-connections-btn:hover, +.chat-header-connections-btn:focus-visible { + border-color: rgba(111, 208, 255, 0.38); + box-shadow: 0 0 0 1px rgba(111, 208, 255, 0.16), 0 0 16px rgba(64, 172, 255, 0.28); +} + +.chat-header-connections-logo { + width: 24px; + height: 24px; + object-fit: contain; + display: block; +} + +.chat-header-avatar-btn { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chat-header-avatar-btn .chat-header-avatar-slot { + display: inline-flex; +} + /* Пузырям сообщений не нужна отдельная обводка: направление читается по фону и геометрии. */ .dm-chat-screen .bubble, .dm-chat-screen .bubble.in,