Files
glchat/internal/store/guilds.go
T

617 lines
19 KiB
Go
Raw Normal View History

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
}