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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user