289 lines
10 KiB
Go
289 lines
10 KiB
Go
|
|
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()
|
|||
|
|
}
|