package store import ( "context" "database/sql" "time" ) // Статистика инстанса и карточка пользователя для расширенной админ-панели // (AGENT.md 7.18, 3.2). Все запросы — только чтение и только для // администратора инстанса: проверка прав живёт в серверном слое. // DailyCount — точка роста: день (UTC) и количество событий за него. type DailyCount struct { Date string Count int } // InstanceUserStats — пользователи инстанса: всего, администраторы, баны, // новые за неделю и месяц, активные за сутки и рост по дням. type InstanceUserStats struct { Total int Admins int Banned int New7Days int New30Days int Active24h int Daily []DailyCount } // InstanceMessageStats — сообщения инстанса: всего, за сутки, неделю, месяц и // рост по дням за месяц. type InstanceMessageStats struct { Total int Today int Week int Month int Daily []DailyCount } // InstanceFileStats — хранилище файлов: сколько файлов и сколько байт. type InstanceFileStats struct { Count int Bytes int64 } // InstanceGuildStat — сервер в топе: участники и живые сообщения. type InstanceGuildStat struct { ID uint64 Name string Members int Messages int } // InstanceStats — сводка по инстансу для вкладки «Статистика». type InstanceStats struct { Users InstanceUserStats Messages InstanceMessageStats Files InstanceFileStats Guilds int Invites int Bans int TopGuilds []InstanceGuildStat BusiestGuilds []InstanceGuildStat } // statsGrowthDays — окно роста по дням: месяц показывает и всплески, и // общую динамику, не раздувая ответ. const statsGrowthDays = 30 // topGuildsLimit — сколько серверов показывать в каждом топе. const topGuildsLimit = 5 // instanceStatsSince возвращает границу окна в формате хранения времени. func (s *Store) instanceStatsSince(days int) string { return s.Timestamp(time.Now().UTC().AddDate(0, 0, -days)) } // InstanceStats собирает сводку по инстансу (AGENT.md 7.18). Запросы идут по // существующим индексам; подсчёт сообщений — по живых записям (удалённые не // считаем: «рост сообщений» должен отражать переписку, а не историю правок). func (s *Store) InstanceStats(ctx context.Context) (*InstanceStats, error) { stats := &InstanceStats{} since30 := s.instanceStatsSince(30) // Пользователи: один запрос с условными суммами дешевле пяти. if err := s.reader.QueryRowContext(ctx, ` SELECT COUNT(*), COALESCE(SUM(is_instance_admin = 1), 0), COALESCE(SUM(created_at >= ?), 0), COALESCE(SUM(created_at >= ?), 0), COALESCE(SUM(last_seen_at >= ?), 0) FROM users WHERE deleted_at IS NULL`, s.instanceStatsSince(7), since30, s.instanceStatsSince(1), ).Scan(&stats.Users.Total, &stats.Users.Admins, &stats.Users.New7Days, &stats.Users.New30Days, &stats.Users.Active24h); err != nil { return nil, err } if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM instance_bans`).Scan(&stats.Bans); err != nil { return nil, err } stats.Users.Banned = stats.Bans dailyUsers, err := s.dailyCounts(ctx, ` SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM users WHERE deleted_at IS NULL AND created_at >= ? GROUP BY day ORDER BY day`, since30) if err != nil { return nil, err } stats.Users.Daily = dailyUsers // Сообщения: всего, за сутки, неделю и месяц. Удалённые сообщения в базе не // хранятся (удаление физическое), поэтому отдельного условия нет. if err := s.reader.QueryRowContext(ctx, ` SELECT COUNT(*), COALESCE(SUM(created_at >= ?), 0), COALESCE(SUM(created_at >= ?), 0), COALESCE(SUM(created_at >= ?), 0) FROM messages`, s.instanceStatsSince(1), s.instanceStatsSince(7), since30, ).Scan(&stats.Messages.Total, &stats.Messages.Today, &stats.Messages.Week, &stats.Messages.Month); err != nil { return nil, err } dailyMessages, err := s.dailyCounts(ctx, ` SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM messages WHERE created_at >= ? GROUP BY day ORDER BY day`, since30) if err != nil { return nil, err } stats.Messages.Daily = dailyMessages if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM files`). Scan(&stats.Files.Count, &stats.Files.Bytes); err != nil { return nil, err } if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guilds`).Scan(&stats.Guilds); err != nil { return nil, err } // Действующие инвайты: отозванные удаляются из таблицы, просроченные // отсекаем по времени. if err := s.reader.QueryRowContext(ctx, ` SELECT COUNT(*) FROM invites WHERE expires_at IS NULL OR expires_at > ?`, s.Now()).Scan(&stats.Invites); err != nil { return nil, err } if stats.TopGuilds, err = s.topGuilds(ctx, "members"); err != nil { return nil, err } if stats.BusiestGuilds, err = s.topGuilds(ctx, "messages"); err != nil { return nil, err } return stats, nil } // dailyCounts выполняет запрос «день → количество» и складывает результат. func (s *Store) dailyCounts(ctx context.Context, query string, args ...any) ([]DailyCount, error) { rows, err := s.reader.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() counts := make([]DailyCount, 0, statsGrowthDays) for rows.Next() { var item DailyCount if err := rows.Scan(&item.Date, &item.Count); err != nil { return nil, err } counts = append(counts, item) } return counts, rows.Err() } // topGuilds отдаёт верхушку серверов по участникам или по живым сообщениям. // Подзапросы вместо JOIN GROUP BY: у `messages` индекс по (channel_id, id), и // коррелированный подзапрос читает только нужные строки. func (s *Store) topGuilds(ctx context.Context, order string) ([]InstanceGuildStat, error) { orderBy := "members DESC, g.id" if order == "messages" { orderBy = "messages DESC, g.id" } rows, err := s.reader.QueryContext(ctx, ` SELECT g.id, g.name, (SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) AS members, (SELECT COUNT(*) FROM messages msg JOIN channels c ON c.id = msg.channel_id WHERE c.guild_id = g.id) AS messages FROM guilds g ORDER BY `+orderBy+` LIMIT ?`, topGuildsLimit) if err != nil { return nil, err } defer rows.Close() guilds := make([]InstanceGuildStat, 0, topGuildsLimit) for rows.Next() { var item InstanceGuildStat if err := rows.Scan(&item.ID, &item.Name, &item.Members, &item.Messages); err != nil { return nil, err } guilds = append(guilds, item) } return guilds, rows.Err() } // MembershipRole — роль участника сервера для карточки пользователя. type MembershipRole struct { ID uint64 Name string Color int64 } // UserGuildMembership — участие пользователя в сервере (AGENT.md 7.18): // ник, дата вступления и роли — администратору инстанса видно везде. type UserGuildMembership struct { GuildID uint64 GuildName string Nickname string JoinedAt time.Time Roles []MembershipRole } // ListUserGuildMemberships отдаёт серверы пользователя с ролями: два запроса // вместо N+1 по серверам. func (s *Store) ListUserGuildMemberships(ctx context.Context, userID uint64) ([]UserGuildMembership, error) { memberships, err := func() ([]UserGuildMembership, error) { rows, err := s.reader.QueryContext(ctx, ` SELECT m.guild_id, g.name, m.nickname, m.joined_at FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.user_id = ? ORDER BY m.joined_at, g.id`, int64(userID)) if err != nil { return nil, err } defer rows.Close() collected := make([]UserGuildMembership, 0, 8) for rows.Next() { var ( membership UserGuildMembership nickname sql.NullString joinedAt string ) if err := rows.Scan(&membership.GuildID, &membership.GuildName, &nickname, &joinedAt); err != nil { return nil, err } if nickname.Valid { membership.Nickname = nickname.String } membership.JoinedAt = parseTimestamp(joinedAt) membership.Roles = []MembershipRole{} collected = append(collected, membership) } return collected, rows.Err() }() if err != nil { return nil, err } // Индекс «сервер → место в списке»: роли приходят вторым запросом. index := make(map[uint64]int, len(memberships)) for position, membership := range memberships { index[membership.GuildID] = position } roleRows, err := s.reader.QueryContext(ctx, ` SELECT mr.guild_id, r.id, r.name, r.color FROM member_roles mr JOIN roles r ON r.id = mr.role_id WHERE mr.user_id = ? ORDER BY r.position DESC, r.id`, int64(userID)) if err != nil { return nil, err } defer roleRows.Close() for roleRows.Next() { var ( role MembershipRole guildID uint64 ) if err := roleRows.Scan(&guildID, &role.ID, &role.Name, &role.Color); err != nil { return nil, err } if position, ok := index[guildID]; ok { memberships[position].Roles = append(memberships[position].Roles, role) } } return memberships, roleRows.Err() }