3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (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'ом при старте.
617 lines
19 KiB
Go
617 lines
19 KiB
Go
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
|
||
}
|