Files
glchat/internal/store/guilds.go
T
grendervill 3dc200c196 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'ом при старте.
2026-09-26 16:44:49 +03:00

617 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
)
type Guild struct {
ID uint64
Name string
OwnerID uint64
IconFileID *uint64
BannerFileID *uint64
SplashFileID *uint64
AccentColor int64
Description string
IsMain bool
IsDiscoverable bool
Limits map[string]int
CreatedAt time.Time
}
type GuildMember struct {
GuildID uint64
UserID uint64
Nickname string
JoinedAt time.Time
TimeoutUntil *time.Time
}
// CreateGuildParams — параметры создания сервера. Роли по умолчанию создаёт
// вызывающий код (bootstrap или создатель сервера), чтобы можно было
// применить шаблон оформления (AGENT.md 7.3).
type CreateGuildParams struct {
ID uint64
Name string
OwnerID uint64
IsMain bool
IsDiscoverable bool
Description string
AccentColor int64
Limits map[string]int
}
const guildColumns = `id, name, owner_id, icon_file_id, banner_file_id, splash_file_id,
accent_color, description, is_main, is_discoverable, limits_json, created_at`
func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Guild, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
limits := "{}"
if len(params.Limits) > 0 {
if encoded, err := json.Marshal(params.Limits); err == nil {
limits = string(encoded)
}
}
_, err := s.writer.ExecContext(ctx, `
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,
strings.ToLower(params.Name), s.Now())
if err != nil {
return nil, err
}
return s.GetGuild(ctx, params.ID)
}
func (s *Store) GetGuild(ctx context.Context, id uint64) (*Guild, error) {
return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE id = ?`, int64(id)))
}
// GetAdminGuild возвращает сервер, созданный установщиком (справочно).
func (s *Store) GetAdminGuild(ctx context.Context) (*Guild, error) {
settings, err := s.InstanceSettings(ctx)
if err != nil {
return nil, err
}
if settings.MainGuildID == 0 {
return nil, ErrNotFound
}
return s.GetGuild(ctx, settings.MainGuildID)
}
func (s *Store) GetMainGuild(ctx context.Context) (*Guild, error) {
return scanGuild(s.reader.QueryRowContext(ctx,
`SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`))
}
type UpdateGuildParams struct {
Name *string
Description *string
AccentColor *int64
IconFileID *uint64
BannerFileID *uint64
SplashFileID *uint64
IsDiscoverable *bool
Limits map[string]int
}
func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildParams) (*Guild, error) {
var (
sets []string
args []any
)
if params.Name != nil {
sets = append(sets, "name = ?", "name_lower = ?")
args = append(args, *params.Name, strings.ToLower(*params.Name))
}
if params.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *params.Description)
}
if params.AccentColor != nil {
sets = append(sets, "accent_color = ?")
args = append(args, *params.AccentColor)
}
if params.IconFileID != nil {
sets = append(sets, "icon_file_id = ?")
args = append(args, int64(*params.IconFileID))
}
if params.BannerFileID != nil {
sets = append(sets, "banner_file_id = ?")
args = append(args, int64(*params.BannerFileID))
}
if params.SplashFileID != nil {
sets = append(sets, "splash_file_id = ?")
args = append(args, int64(*params.SplashFileID))
}
if params.IsDiscoverable != nil {
sets = append(sets, "is_discoverable = ?")
args = append(args, boolToInt(*params.IsDiscoverable))
}
if params.Limits != nil {
encoded, err := json.Marshal(params.Limits)
if err != nil {
return nil, err
}
sets = append(sets, "limits_json = ?")
args = append(args, string(encoded))
}
if len(sets) == 0 {
return s.GetGuild(ctx, id)
}
args = append(args, int64(id))
result, err := s.writer.ExecContext(ctx, buildQuery(updateGuildTemplate, joinSets(sets)), args...)
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetGuild(ctx, id)
}
func (s *Store) DeleteGuild(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, int64(id))
return err
}
// ListGuildsForUser возвращает серверы пользователя (участие).
func (s *Store) ListGuildsForUser(ctx context.Context, userID uint64) ([]Guild, error) {
rows, err := s.reader.QueryContext(ctx, buildQuery(selectGuildsForUserTemplate, prefixColumns("g", guildColumns)), int64(userID))
if err != nil {
return nil, err
}
return collectGuilds(rows)
}
// ListAllGuilds используется инстанс-админом (AGENT.md 7.19).
func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) {
rows, err := s.reader.QueryContext(ctx, `SELECT `+guildColumns+` FROM guilds ORDER BY id`)
if err != nil {
return nil, err
}
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)
return count, err
}
func (s *Store) CountGuildMembers(ctx context.Context, guildID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE guild_id = ?`, int64(guildID)).Scan(&count)
return count, err
}
func (s *Store) AddGuildMember(ctx context.Context, guildID, userID uint64, nickname string) (*GuildMember, error) {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO guild_members (guild_id, user_id, nickname, joined_at) VALUES (?, ?, ?, ?)
ON CONFLICT (guild_id, user_id) DO NOTHING`,
int64(guildID), int64(userID), nullableString(nickname), s.Now())
if err != nil {
return nil, err
}
return s.GetGuildMember(ctx, guildID, userID)
}
func (s *Store) GetGuildMember(ctx context.Context, guildID, userID uint64) (*GuildMember, error) {
var (
member GuildMember
nickname sql.NullString
joinedAt string
timeoutUntil sql.NullString
)
err := s.reader.QueryRowContext(ctx, `
SELECT guild_id, user_id, nickname, joined_at, timeout_until
FROM guild_members WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)).
Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil)
if err != nil {
return nil, mapError(err)
}
if nickname.Valid {
member.Nickname = nickname.String
}
member.JoinedAt = parseTimestamp(joinedAt)
if timeoutUntil.Valid {
value := parseTimestamp(timeoutUntil.String)
member.TimeoutUntil = &value
}
return &member, nil
}
func (s *Store) ListGuildMembers(ctx context.Context, guildID uint64) ([]GuildMember, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT guild_id, user_id, nickname, joined_at, timeout_until
FROM guild_members WHERE guild_id = ? ORDER BY joined_at`, int64(guildID))
if err != nil {
return nil, err
}
defer rows.Close()
members := make([]GuildMember, 0, 16)
for rows.Next() {
var (
member GuildMember
nickname sql.NullString
joinedAt string
timeoutUntil sql.NullString
)
if err := rows.Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil); err != nil {
return nil, err
}
if nickname.Valid {
member.Nickname = nickname.String
}
member.JoinedAt = parseTimestamp(joinedAt)
if timeoutUntil.Valid {
value := parseTimestamp(timeoutUntil.String)
member.TimeoutUntil = &value
}
members = append(members, member)
}
return members, rows.Err()
}
func (s *Store) UpdateGuildMember(ctx context.Context, guildID, userID uint64, nickname *string, timeoutUntil *time.Time) error {
sets := []string{}
args := []any{}
if nickname != nil {
sets = append(sets, "nickname = ?")
args = append(args, nullableString(*nickname))
}
if timeoutUntil != nil {
if timeoutUntil.IsZero() {
sets = append(sets, "timeout_until = NULL")
} else {
sets = append(sets, "timeout_until = ?")
args = append(args, s.Timestamp(*timeoutUntil))
}
}
if len(sets) == 0 {
return nil
}
args = append(args, int64(guildID), int64(userID))
_, err := s.writer.ExecContext(ctx, buildQuery(updateGuildMemberTemplate, joinSets(sets)), args...)
return err
}
// TimeoutFromNow возвращает момент окончания тайм-аута (AGENT.md 7.10).
func TimeoutFromNow(days int) time.Time {
if days < 1 {
days = 1
}
return time.Now().UTC().AddDate(0, 0, days)
}
func (s *Store) RemoveGuildMember(ctx context.Context, guildID, userID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM guild_members WHERE guild_id = ? AND user_id = ?`,
int64(guildID), int64(userID))
return err
}
func (s *Store) TransferGuildOwnership(ctx context.Context, guildID, newOwnerID uint64) error {
result, err := s.writer.ExecContext(ctx, `UPDATE guilds SET owner_id = ? WHERE id = ?`,
int64(newOwnerID), int64(guildID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func scanGuild(scanner interface{ Scan(...any) error }, extra ...any) (*Guild, error) {
var (
guild Guild
iconID sql.NullInt64
bannerID sql.NullInt64
splashID sql.NullInt64
isMain int
discover int
limitsJSON string
createdAt string
)
// 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)
}
guild.IconFileID = optionalID(iconID)
guild.BannerFileID = optionalID(bannerID)
guild.SplashFileID = optionalID(splashID)
guild.IsMain = isMain == 1
guild.IsDiscoverable = discover == 1
if limitsJSON != "" && limitsJSON != "{}" {
_ = json.Unmarshal([]byte(limitsJSON), &guild.Limits)
}
guild.CreatedAt = parseTimestamp(createdAt)
return &guild, nil
}
func collectGuilds(rows *sql.Rows) ([]Guild, error) {
defer rows.Close()
guilds := make([]Guild, 0, 8)
for rows.Next() {
guild, err := scanGuild(rows)
if err != nil {
return nil, err
}
guilds = append(guilds, *guild)
}
return guilds, rows.Err()
}
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
func joinSets(sets []string) string {
return strings.Join(sets, ", ")
}
// prefixColumns добавляет префикс таблицы к списку колонок.
func prefixColumns(prefix, columns string) string {
parts := strings.Split(columns, ",")
for i, part := range parts {
parts[i] = prefix + "." + strings.TrimSpace(part)
}
return strings.Join(parts, ", ")
}
// GuildMemberProfile — участник сервера вместе с публичными полями профиля:
// нужен списку участников в клиенте (AGENT.md 8.2).
type GuildMemberProfile struct {
UserID uint64
Username string
DisplayName string
Nickname string
AvatarFileID *uint64
Status string
CustomStatus string
IsInstanceAdmin bool
JoinedAt time.Time
TimeoutUntil *time.Time
RoleIDs []uint64
// Badges — системные бейджи пользователя (AGENT.md 7.2): список
// расширяемый, назначается только системой.
Badges []string
}
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id,
u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until,
u.badges_json
FROM guild_members m
JOIN users u ON u.id = m.user_id
WHERE m.guild_id = ? AND u.deleted_at IS NULL
ORDER BY m.joined_at, m.user_id`, int64(guildID))
if err != nil {
return nil, err
}
defer rows.Close()
profiles := make([]GuildMemberProfile, 0, 16)
for rows.Next() {
var (
profile GuildMemberProfile
nickname sql.NullString
avatarID sql.NullInt64
isAdmin int
joinedAt string
timeoutUntil sql.NullString
badges string
)
if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname,
&avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil,
&badges); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
profile.Badges = nil
}
if nickname.Valid {
profile.Nickname = nickname.String
}
if avatarID.Valid {
value := uint64(avatarID.Int64)
profile.AvatarFileID = &value
}
profile.IsInstanceAdmin = isAdmin == 1
profile.JoinedAt = parseTimestamp(joinedAt)
if timeoutUntil.Valid {
value := parseTimestamp(timeoutUntil.String)
profile.TimeoutUntil = &value
}
profiles = append(profiles, profile)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Роли участников читаем одним запросом и раскладываем по участникам.
roleRows, err := s.reader.QueryContext(ctx, `
SELECT mr.user_id, mr.role_id
FROM member_roles mr
JOIN guild_members m ON m.guild_id = mr.guild_id AND m.user_id = mr.user_id
WHERE mr.guild_id = ?
ORDER BY mr.role_id`, int64(guildID))
if err != nil {
return nil, err
}
defer roleRows.Close()
index := make(map[uint64]int, len(profiles))
for i, profile := range profiles {
index[profile.UserID] = i
}
for roleRows.Next() {
var userID, roleID uint64
if err := roleRows.Scan(&userID, &roleID); err != nil {
return nil, err
}
if position, ok := index[userID]; ok {
profiles[position].RoleIDs = append(profiles[position].RoleIDs, roleID)
}
}
return profiles, roleRows.Err()
}
// CountGuildMemberships возвращает число серверов, которыми владеет пользователь
// и в которых состоит: используется для лимитов (AGENT.md 6.5).
func (s *Store) CountGuildMemberships(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)
return count, err
}