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)
}