Files
grendervill 3dc200c196 feat(api): расширенная админ-панель инстанса (Фаза 7)
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (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'ом при старте.
2026-09-26 16:44:49 +03:00

289 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}