Files

289 lines
10 KiB
Go
Raw Permalink Normal View History

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()
}