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'ом при старте.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user