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:
+144
-8
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user