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:
2026-09-26 16:44:49 +03:00
parent 09b4f6878f
commit 3dc200c196
12 changed files with 2323 additions and 47 deletions
+144 -8
View File
@@ -59,10 +59,11 @@ func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Gui
}
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, name_lower, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), params.Name, int64(params.OwnerID), params.Description, params.AccentColor,
boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits, s.Now())
boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits,
strings.ToLower(params.Name), s.Now())
if err != nil {
return nil, err
}
@@ -107,8 +108,8 @@ func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildPa
args []any
)
if params.Name != nil {
sets = append(sets, "name = ?")
args = append(args, *params.Name)
sets = append(sets, "name = ?", "name_lower = ?")
args = append(args, *params.Name, strings.ToLower(*params.Name))
}
if params.Description != nil {
sets = append(sets, "description = ?")
@@ -179,6 +180,134 @@ func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) {
return collectGuilds(rows)
}
// BackfillGuildNameLower заполняет нормализованное название у серверов,
// созданных до появления колонки: поиск в админ-панели инстанса не должен
// зависеть от регистра (AGENT.md 7.18). Вызывается один раз при старте,
// порциями — как BackfillSearchIndex у сообщений.
func (s *Store) BackfillGuildNameLower(ctx context.Context, limit int) (int, error) {
if limit <= 0 || limit > 5000 {
limit = 500
}
type item struct {
id uint64
name string
}
// Читаем порцию и закрываем строки до записи: соединение чтения не должно
// держать курсор, пока идёт обновление.
items, err := func() ([]item, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT id, name FROM guilds WHERE name_lower = '' AND name <> '' LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
collected := make([]item, 0, limit)
for rows.Next() {
var entry item
if err := rows.Scan(&entry.id, &entry.name); err != nil {
return nil, err
}
collected = append(collected, entry)
}
return collected, rows.Err()
}()
if err != nil {
return 0, err
}
updated := 0
for _, entry := range items {
if _, err := s.writer.ExecContext(ctx,
`UPDATE guilds SET name_lower = ? WHERE id = ?`,
strings.ToLower(entry.name), int64(entry.id)); err != nil {
return updated, err
}
updated++
}
return updated, nil
}
// InstanceGuildRow — строка списка серверов инстанса: сам сервер, имя
// владельца и число участников одним запросом (AGENT.md 7.18).
type InstanceGuildRow struct {
Guild
OwnerName string
MemberCount int
}
// InstanceGuildFilter — фильтры списка серверов инстанса: поиск по названию,
// владелец, только главный сервер и страница выдачи.
type InstanceGuildFilter struct {
Query string
OwnerID uint64
MainOnly bool
Limit int
Offset int
}
// ListInstanceGuilds отдаёт страницу серверов вместе с общим числом под тот же
// фильтр: панели нужны и поиск, и пагинация, а прежний список отдавал сразу
// все серверы инстанса (AGENT.md 7.18).
func (s *Store) ListInstanceGuilds(ctx context.Context, filter InstanceGuildFilter) ([]InstanceGuildRow, int, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 200 {
filter.Limit = 200
}
if filter.Offset < 0 {
filter.Offset = 0
}
clauses := []string{"1 = 1"}
args := make([]any, 0, 4)
if trimmed := strings.TrimSpace(filter.Query); trimmed != "" {
// Сравниваем с нормализованной копией названия: SQLite LIKE не
// приводит регистр кириллицы (AGENT.md 7.18).
clauses = append(clauses, "g.name_lower LIKE ?")
args = append(args, "%"+strings.ToLower(trimmed)+"%")
}
if filter.OwnerID != 0 {
clauses = append(clauses, "g.owner_id = ?")
args = append(args, mustID(filter.OwnerID))
}
if filter.MainOnly {
clauses = append(clauses, "g.is_main = 1")
}
where := strings.Join(clauses, " AND ")
var total int
if err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM guilds g WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
queryArgs := make([]any, 0, len(args)+2)
queryArgs = append(queryArgs, args...)
queryArgs = append(queryArgs, filter.Limit, filter.Offset)
rows, err := s.reader.QueryContext(ctx, `
SELECT `+prefixColumns("g", guildColumns)+`,
COALESCE(u.username, ''),
(SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id)
FROM guilds g LEFT JOIN users u ON u.id = g.owner_id
WHERE `+where+` ORDER BY g.id LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
guilds := make([]InstanceGuildRow, 0, filter.Limit)
for rows.Next() {
var row InstanceGuildRow
guild, err := scanGuild(rows, &row.OwnerName, &row.MemberCount)
if err != nil {
return nil, 0, err
}
row.Guild = *guild
guilds = append(guilds, row)
}
return guilds, total, rows.Err()
}
func (s *Store) CountGuildsForUser(ctx context.Context, userID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count)
@@ -309,7 +438,7 @@ func (s *Store) TransferGuildOwnership(ctx context.Context, guildID, newOwnerID
return nil
}
func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) {
func scanGuild(scanner interface{ Scan(...any) error }, extra ...any) (*Guild, error) {
var (
guild Guild
iconID sql.NullInt64
@@ -320,8 +449,15 @@ func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) {
limitsJSON string
createdAt string
)
err := scanner.Scan(&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID,
&guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt)
// extra — дополнительные колонки запроса (например, имя владельца и число
// участников в списке инстанса): порядок сканирования остаётся прежним.
dest := make([]any, 0, 12+len(extra))
dest = append(dest,
&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID,
&guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt,
)
dest = append(dest, extra...)
err := scanner.Scan(dest...)
if err != nil {
return nil, mapError(err)
}
+140 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"math"
"strconv"
"strings"
"time"
)
@@ -216,18 +217,149 @@ func parseUint(value string) (uint64, bool) {
return result, true
}
// ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов
// инстанса (в том числе внутри серверов) и записи без привязки к серверу,
// которые переживают удаление сервера (AGENT.md 7.18).
func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
// MaxInstanceAuditLimit — предел выдачи журнала инстанса за один запрос:
// панель листает страницы по 50–200 строк, экспорт CSV берёт до 5000
// (AGENT.md 7.10, 7.18). Больше не отдаём: выгрузка всего журнала одним
// ответом съедала бы память на 1 vCPU / 2 GB.
const MaxInstanceAuditLimit = 5000
// InstanceAuditFilter — условия выборки журнала инстанса (AGENT.md 7.10):
// действие, актор, цель, сервер и период. Незаполненные поля не ограничивают
// выборку, поэтому один тип используется и панелью, и экспортом.
type InstanceAuditFilter struct {
Action string
ActorID uint64
TargetID uint64
GuildID uint64
Since *time.Time
Until *time.Time
Limit int
Offset int
}
// instanceAuditConditions собирает WHERE для журнала инстанса: базовая область
// — действия администраторов инстанса (в том числе внутри серверов) и записи
// без привязки к серверу, которые переживают удаление сервера (AGENT.md 7.18).
// Время сравнивается строками: в базе лежит ISO 8601 UTC с миллисекундами,
// поэтому лексикографический порядок совпадает с хронологическим.
func (s *Store) instanceAuditConditions(filter InstanceAuditFilter) (string, []any) {
clauses := []string{"(actor_instance_admin = 1 OR guild_id IS NULL)"}
args := make([]any, 0, 6)
if filter.Action != "" {
clauses = append(clauses, "action = ?")
args = append(args, filter.Action)
}
if filter.ActorID != 0 {
clauses = append(clauses, "actor_id = ?")
args = append(args, mustID(filter.ActorID))
}
if filter.TargetID != 0 {
clauses = append(clauses, "target_id = ?")
args = append(args, mustID(filter.TargetID))
}
if filter.GuildID != 0 {
clauses = append(clauses, "guild_id = ?")
args = append(args, mustID(filter.GuildID))
}
if filter.Since != nil {
clauses = append(clauses, "created_at >= ?")
args = append(args, s.Timestamp(*filter.Since))
}
if filter.Until != nil {
clauses = append(clauses, "created_at <= ?")
args = append(args, s.Timestamp(*filter.Until))
}
return strings.Join(clauses, " AND "), args
}
// ListInstanceAuditFiltered отдаёт страницу журнала инстанса вместе с общим
// числом записей под тот же фильтр: панели нужна пагинация, а не только
// первые N строк (AGENT.md 7.18).
func (s *Store) ListInstanceAuditFiltered(ctx context.Context, filter InstanceAuditFilter) ([]AuditEntry, int, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > MaxInstanceAuditLimit {
filter.Limit = MaxInstanceAuditLimit
}
if filter.Offset < 0 {
filter.Offset = 0
}
where, args := s.instanceAuditConditions(filter)
var total int
if err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM audit_log WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
queryArgs := make([]any, 0, len(args)+2)
queryArgs = append(queryArgs, args...)
queryArgs = append(queryArgs, filter.Limit, filter.Offset)
rows, err := s.reader.QueryContext(ctx, `
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
FROM audit_log WHERE actor_instance_admin = 1 OR guild_id IS NULL ORDER BY id DESC LIMIT ?`, limit)
FROM audit_log WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, 0, err
}
entries, err := scanAuditEntries(rows, filter.Limit)
if err != nil {
return nil, 0, err
}
return entries, total, nil
}
// ListInstanceAuditActions перечисляет действия, которые встречаются в журнале
// инстанса: панель предлагает их списком, чтобы фильтр не набирали руками.
func (s *Store) ListInstanceAuditActions(ctx context.Context) ([]string, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT DISTINCT action FROM audit_log
WHERE actor_instance_admin = 1 OR guild_id IS NULL
ORDER BY action`)
if err != nil {
return nil, err
}
return scanAuditEntries(rows, limit)
defer rows.Close()
actions := make([]string, 0, 16)
for rows.Next() {
var action string
if err := rows.Scan(&action); err != nil {
return nil, err
}
actions = append(actions, action)
}
return actions, rows.Err()
}
// ListUserAudit — история аудита по конкретному пользователю для его карточки:
// записи, где он цель (target_type = user) или актор (AGENT.md 7.18). Личные
// беседы в аудит не попадают вовсе, поэтому приватность переписки не
// нарушается даже полной выборкой по актору.
func (s *Store) ListUserAudit(ctx context.Context, userID uint64, limit, offset int) ([]AuditEntry, int, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
const where = "(target_type = 'user' AND target_id = ?) OR actor_id = ?"
var total int
if err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM audit_log WHERE `+where, mustID(userID), mustID(userID)).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.reader.QueryContext(ctx, `
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
FROM audit_log WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`,
mustID(userID), mustID(userID), limit, offset)
if err != nil {
return nil, 0, err
}
entries, err := scanAuditEntries(rows, limit)
if err != nil {
return nil, 0, err
}
return entries, total, nil
}
+288
View File
@@ -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()
}
+364
View File
@@ -0,0 +1,364 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"glchat/internal/database"
)
// newInstanceStore открывает временную БД с миграциями: расширенная
// админ-панель (AGENT.md 7.18) читает пользователей, серверы, сообщения и
// аудит, поэтому нужен настоящий набор таблиц.
func newInstanceStore(t *testing.T) *Store {
t.Helper()
db, err := database.Open(context.Background(), database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("открыть тестовую БД: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("закрыть тестовую БД: %v", err)
}
})
return New(db)
}
func createInstanceUser(t *testing.T, st *Store, username string) *User {
t.Helper()
user, err := st.CreateUser(context.Background(), CreateUserParams{
Username: username,
EmailEnc: "enc-" + username,
EmailIndex: "idx-" + username,
PasswordHash: "hash",
})
if err != nil {
t.Fatalf("создать пользователя %s: %v", username, err)
}
return user
}
// TestInstanceStatsAggregates проверяет сводку панели: пользователи, сообщения,
// файлы, серверы и топы (AGENT.md 7.18).
func TestInstanceStatsAggregates(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
owner := createInstanceUser(t, st, "stats_owner")
member := createInstanceUser(t, st, "stats_member")
if err := st.BanInstanceUser(ctx, member.ID, owner.ID, "спам"); err != nil {
t.Fatalf("забанить пользователя: %v", err)
}
if err := st.SetInstanceAdmin(ctx, member.ID, true); err != nil {
t.Fatalf("выдать админа: %v", err)
}
guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Статистика", OwnerID: owner.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
for _, userID := range []uint64{owner.ID, member.ID} {
if _, err := st.AddGuildMember(ctx, guild.ID, userID, ""); err != nil {
t.Fatalf("добавить участника: %v", err)
}
}
channel, err := st.CreateChannel(ctx, CreateChannelParams{
GuildID: &guild.ID, Type: ChannelText, Name: "общий",
})
if err != nil {
t.Fatalf("создать комнату: %v", err)
}
for i := 0; i < 3; i++ {
if _, err := st.CreateMessage(ctx, CreateMessageParams{
ChannelID: channel.ID, AuthorID: owner.ID, Content: "привет",
}); err != nil {
t.Fatalf("создать сообщение: %v", err)
}
}
// Вложение лежит в files и считается размером хранилища.
if _, err := st.CreateFile(ctx, CreateFileParams{
UploaderID: owner.ID, Filename: "a.png", ContentType: "image/png",
SizeBytes: 2048, StoragePath: "files/a", SHA256: "sha-stats", Purpose: "attachment",
}); err != nil {
t.Fatalf("создать файл: %v", err)
}
if _, err := st.CreateInvite(ctx, CreateInviteParams{
Code: "stats-invite", GuildID: guild.ID, CreatorID: owner.ID, MaxAgeSec: 3600,
}); err != nil {
t.Fatalf("создать инвайт: %v", err)
}
// Отозванный инвайт действующим не считается.
if _, err := st.CreateInvite(ctx, CreateInviteParams{
Code: "stats-revoked", GuildID: guild.ID, CreatorID: owner.ID,
}); err != nil {
t.Fatalf("создать инвайт: %v", err)
}
if err := st.DeleteInvite(ctx, "stats-revoked"); err != nil {
t.Fatalf("отозвать инвайт: %v", err)
}
stats, err := st.InstanceStats(ctx)
if err != nil {
t.Fatalf("InstanceStats: %v", err)
}
if stats.Users.Total < 2 || stats.Users.New7Days < 2 || stats.Users.New30Days < 2 {
t.Fatalf("пользователи в сводке: %+v", stats.Users)
}
if stats.Users.Admins != 1 {
t.Fatalf("администраторов = %d, want 1", stats.Users.Admins)
}
if stats.Bans != 1 || stats.Users.Banned != 1 {
t.Fatalf("банов = %d/%d, want 1", stats.Bans, stats.Users.Banned)
}
if len(stats.Users.Daily) == 0 {
t.Fatal("рост пользователей по дням пуст")
}
if stats.Messages.Total != 3 || stats.Messages.Today != 3 || stats.Messages.Month != 3 {
t.Fatalf("сообщения в сводке: %+v", stats.Messages)
}
if len(stats.Messages.Daily) == 0 {
t.Fatal("рост сообщений по дням пуст")
}
if stats.Files.Count != 1 || stats.Files.Bytes != 2048 {
t.Fatalf("файлы в сводке: %+v", stats.Files)
}
if stats.Guilds != 1 {
t.Fatalf("серверов = %d, want 1", stats.Guilds)
}
if stats.Invites != 1 {
t.Fatalf("действующих инвайтов = %d, want 1", stats.Invites)
}
if len(stats.TopGuilds) != 1 || stats.TopGuilds[0].Members != 2 {
t.Fatalf("топ по участникам: %+v", stats.TopGuilds)
}
if len(stats.BusiestGuilds) != 1 || stats.BusiestGuilds[0].Messages != 3 {
t.Fatalf("топ по сообщениям: %+v", stats.BusiestGuilds)
}
}
// TestInstanceAuditFilters: журнал инстанса фильтруется по действию, актору,
// цели, серверу и периоду, а выдача листается (AGENT.md 7.10, 7.18).
func TestInstanceAuditFilters(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
admin := createInstanceUser(t, st, "audit_admin")
target := createInstanceUser(t, st, "audit_target")
if err := st.SetInstanceAdmin(ctx, admin.ID, true); err != nil {
t.Fatalf("выдать админа: %v", err)
}
record := func(action, targetType string, targetID *uint64, reason string) {
t.Helper()
if err := st.RecordAudit(ctx, AuditParams{
ActorID: &admin.ID, ActorInstanceAdmin: true, Action: action,
TargetType: targetType, TargetID: targetID, Reason: reason,
}); err != nil {
t.Fatalf("записать аудит %s: %v", action, err)
}
}
record("instance.user_ban", "user", &target.ID, "спам")
record("instance.user_unban", "user", &target.ID, "")
record("instance.settings_update", "instance", nil, "")
// Запись обычного пользователя внутри сервера в журнал инстанса не
// попадает, а запись без сервера (например, ключ доступа) — попадает:
// так устроена область журнала (AGENT.md 7.18).
someone := createInstanceUser(t, st, "audit_someone")
guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Аудит", OwnerID: someone.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
if err := st.RecordAudit(ctx, AuditParams{
ActorID: &someone.ID, GuildID: &guild.ID, Action: "channel.create", TargetType: "channel",
}); err != nil {
t.Fatalf("записать аудит сервера: %v", err)
}
if err := st.RecordAudit(ctx, AuditParams{
ActorID: &someone.ID, Action: "user.passkey_add", TargetType: "user", TargetID: &someone.ID,
}); err != nil {
t.Fatalf("записать аудит без сервера: %v", err)
}
all, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{})
if err != nil {
t.Fatalf("журнал целиком: %v", err)
}
if total != 4 || len(all) != 4 {
t.Fatalf("журнал = %d строк (total %d), want 4", len(all), total)
}
byAction, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Action: "instance.user_ban"})
if err != nil || total != 1 || len(byAction) != 1 {
t.Fatalf("фильтр по действию: %d строк (total %d), err = %v", len(byAction), total, err)
}
byTarget, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{TargetID: target.ID})
if err != nil || total != 2 {
t.Fatalf("фильтр по цели: %d (total %d), err = %v", len(byTarget), total, err)
}
byActor, actorTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{ActorID: someone.ID})
if err != nil || actorTotal != 1 || len(byActor) != 1 {
t.Fatalf("записи без сервера по актору: %d (total %d), err = %v", len(byActor), actorTotal, err)
}
_, guildTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{GuildID: guild.ID})
if err != nil || guildTotal != 0 {
t.Fatalf("аудит сервера не должен попадать в журнал инстанса: total %d, err = %v", guildTotal, err)
}
// Период: сегодняшние записи видны, «завтра» — нет.
tomorrow := time.Now().UTC().AddDate(0, 0, 1)
future, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &tomorrow})
if err != nil || total != 0 || len(future) != 0 {
t.Fatalf("фильтр «с завтра»: %d (total %d), err = %v", len(future), total, err)
}
yesterday := time.Now().UTC().AddDate(0, 0, -1)
recent, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &yesterday})
if err != nil || total != 4 {
t.Fatalf("фильтр «с вчера»: %d (total %d), err = %v", len(recent), total, err)
}
// Пагинация: страницы не пересекаются, total не зависит от страницы.
first, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2})
if err != nil || len(first) != 2 || total != 4 {
t.Fatalf("первая страница: %d (total %d), err = %v", len(first), total, err)
}
second, _, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2, Offset: 3})
if err != nil || len(second) != 1 {
t.Fatalf("вторая страница: %d, err = %v", len(second), err)
}
if first[0].ID == second[0].ID {
t.Fatal("страницы журнала пересекаются")
}
actions, err := st.ListInstanceAuditActions(ctx)
if err != nil {
t.Fatalf("список действий: %v", err)
}
if len(actions) != 4 || actions[0] != "instance.settings_update" || actions[3] != "user.passkey_add" {
t.Fatalf("действия журнала = %v", actions)
}
// История по пользователю: он цель в двух записях.
userEntries, userTotal, err := st.ListUserAudit(ctx, target.ID, 10, 0)
if err != nil || userTotal != 2 || len(userEntries) != 2 {
t.Fatalf("аудит пользователя: %d (total %d), err = %v", len(userEntries), userTotal, err)
}
}
// TestInstanceGuildListFiltersAndMemberships: список серверов панели
// фильтруется и листается, а карточка участника показывает роли (AGENT.md 7.18).
func TestInstanceGuildListFiltersAndMemberships(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
owner := createInstanceUser(t, st, "list_owner")
other := createInstanceUser(t, st, "list_other")
first, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Альфа", OwnerID: owner.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
second, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Бета", OwnerID: other.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
if _, err := st.AddGuildMember(ctx, second.ID, other.ID, ""); err != nil {
t.Fatalf("добавить владельца: %v", err)
}
main, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Главный", OwnerID: owner.ID, IsMain: true})
if err != nil {
t.Fatalf("создать главный сервер: %v", err)
}
if _, err := st.AddGuildMember(ctx, first.ID, owner.ID, ""); err != nil {
t.Fatalf("добавить владельца: %v", err)
}
if _, err := st.AddGuildMember(ctx, first.ID, other.ID, "гость"); err != nil {
t.Fatalf("добавить участника: %v", err)
}
role, err := st.CreateRole(ctx, CreateRoleParams{
GuildID: first.ID, Name: "Модератор", Color: 0xFF0000, Position: 5, Permissions: 8,
})
if err != nil {
t.Fatalf("создать роль: %v", err)
}
if err := st.AssignRole(ctx, first.ID, other.ID, role.ID); err != nil {
t.Fatalf("выдать роль: %v", err)
}
byName, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Query: "альф"})
if err != nil || total != 1 || len(byName) != 1 {
t.Fatalf("поиск по названию: %d (total %d), err = %v", len(byName), total, err)
}
if byName[0].MemberCount != 2 {
t.Fatalf("участников в списке = %d, want 2", byName[0].MemberCount)
}
byOwner, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{OwnerID: owner.ID})
if err != nil || total != 2 {
t.Fatalf("фильтр по владельцу: %d (total %d), err = %v", len(byOwner), total, err)
}
mainOnly, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{MainOnly: true})
if err != nil || total != 1 || mainOnly[0].ID != main.ID {
t.Fatalf("главный сервер: %+v (total %d), err = %v", mainOnly, total, err)
}
page, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Limit: 2, Offset: 2})
if err != nil || len(page) != 1 || total != 3 {
t.Fatalf("страница серверов: %d (total %d), err = %v", len(page), total, err)
}
memberships, err := st.ListUserGuildMemberships(ctx, other.ID)
if err != nil {
t.Fatalf("серверы пользователя: %v", err)
}
if len(memberships) != 2 {
t.Fatalf("серверов у пользователя = %d, want 2", len(memberships))
}
found := false
for _, membership := range memberships {
if membership.GuildID != first.ID {
continue
}
found = true
if membership.Nickname != "гость" || len(membership.Roles) != 1 || membership.Roles[0].Name != "Модератор" {
t.Fatalf("участие в сервере = %+v", membership)
}
}
if !found {
t.Fatal("сервер с ролью не найден в карточке пользователя")
}
}
// TestUserSessionRevokeChecksOwner: отозвать можно только сессию самого
// пользователя, чужая не удаляется (AGENT.md 7.18).
func TestUserSessionRevokeChecksOwner(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
owner := createInstanceUser(t, st, "session_owner")
other := createInstanceUser(t, st, "session_other")
session, err := st.CreateSession(ctx, owner.ID, CreateSessionParams{
TokenHash: "hash-session", UserAgent: "тест", IP: "127.0.0.1",
})
if err != nil {
t.Fatalf("создать сессию: %v", err)
}
if _, err := st.UserSession(ctx, other.ID, session.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("чужая сессия = %v, want ErrNotFound", err)
}
deleted, err := st.DeleteUserSession(ctx, other.ID, session.ID)
if err != nil || deleted {
t.Fatalf("чужое удаление: deleted = %v, err = %v", deleted, err)
}
if _, err := st.UserSession(ctx, owner.ID, session.ID); err != nil {
t.Fatalf("своя сессия недоступна: %v", err)
}
deleted, err = st.DeleteUserSession(ctx, owner.ID, session.ID)
if err != nil || !deleted {
t.Fatalf("своё удаление: deleted = %v, err = %v", deleted, err)
}
if _, err := st.UserSession(ctx, owner.ID, session.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("сессия после отзыва = %v, want ErrNotFound", err)
}
}
+25
View File
@@ -142,6 +142,31 @@ func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepSessionID u
return err
}
// UserSession отдаёт сессию, только если она принадлежит пользователю: админ
// панели отзывает конкретное устройство (AGENT.md 7.18), и чужой
// идентификатор сессии не должен ничего отзывать.
func (s *Store) UserSession(ctx context.Context, userID, sessionID uint64) (*Session, error) {
row := s.reader.QueryRowContext(ctx, `
SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at
FROM sessions WHERE id = ? AND user_id = ?`, int64(sessionID), int64(userID))
return scanSession(row)
}
// DeleteUserSession отзывает одно устройство пользователя. Возвращает false,
// если сессии с таким идентификатором у него нет.
func (s *Store) DeleteUserSession(ctx context.Context, userID, sessionID uint64) (bool, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM sessions WHERE id = ? AND user_id = ?`, int64(sessionID), int64(userID))
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
// DeleteExpiredSessions вызывается cleanup-джобой (AGENT.md 6.4).
func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) {
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now())