3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):
- GET /instance/stats: рост пользователей и сообщений по дням, активность,
размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
одного устройства и сброс второго фактора со step-up, аудитом и записью
в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
lower() не знает кириллицу, поэтому регистр приводит приложение (как для
текста сообщений), старые записи дополняются backfill'ом при старте.
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()
|
||
}
|