162 lines
6.4 KiB
Go
162 lines
6.4 KiB
Go
|
|
package store
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"database/sql"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет
|
|||
|
|
// структуру своего сервера — роли с правами и цветом, категории и комнаты —
|
|||
|
|
// и создаёт по ней новые серверы. Шаблон приватный: список, число и удаление
|
|||
|
|
// всегда фильтруются по owner_id, а чужой шаблон для остальных выглядит
|
|||
|
|
// несуществующим (AGENT.md 9.7).
|
|||
|
|
|
|||
|
|
// Пределы хранения личных шаблонов: не больше 20 шаблонов на пользователя и
|
|||
|
|
// не больше 16 КиБ на structure_json. Те же значения проверяет HTTP-слой,
|
|||
|
|
// чтобы вернуть понятную ошибку (template.limit_reached), а здесь они —
|
|||
|
|
// жёсткая защита базы от обхода лимита гонкой двух запросов.
|
|||
|
|
const (
|
|||
|
|
MaxUserGuildTemplates = 20
|
|||
|
|
MaxUserGuildTemplateBytes = 16 << 10
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
ErrTemplateLimitReached = errors.New("personal guild template limit reached")
|
|||
|
|
ErrTemplateTooLarge = errors.New("personal guild template is too large")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserGuildTemplate — личный шаблон структуры сервера.
|
|||
|
|
type UserGuildTemplate struct {
|
|||
|
|
ID uint64
|
|||
|
|
OwnerID uint64
|
|||
|
|
Name string
|
|||
|
|
Description string
|
|||
|
|
Structure json.RawMessage
|
|||
|
|
SourceGuildID *uint64
|
|||
|
|
CreatedAt time.Time
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateUserGuildTemplateParams — параметры нового шаблона; Structure — уже
|
|||
|
|
// готовый JSON структуры (формат описан в internal/server/guild_templates.go).
|
|||
|
|
type CreateUserGuildTemplateParams struct {
|
|||
|
|
ID uint64
|
|||
|
|
OwnerID uint64
|
|||
|
|
Name string
|
|||
|
|
Description string
|
|||
|
|
Structure json.RawMessage
|
|||
|
|
SourceGuildID *uint64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const userGuildTemplateColumns = `id, owner_id, name, description, structure_json,
|
|||
|
|
source_guild_id, created_at`
|
|||
|
|
|
|||
|
|
// CreateUserGuildTemplate сохраняет шаблон: проверка лимитов и вставка идут
|
|||
|
|
// одной транзакцией, иначе два одновременных запроса обошли бы лимит.
|
|||
|
|
func (s *Store) CreateUserGuildTemplate(ctx context.Context, params CreateUserGuildTemplateParams) (*UserGuildTemplate, error) {
|
|||
|
|
if len(params.Structure) == 0 {
|
|||
|
|
params.Structure = json.RawMessage("{}")
|
|||
|
|
}
|
|||
|
|
if len(params.Structure) > MaxUserGuildTemplateBytes {
|
|||
|
|
return nil, ErrTemplateTooLarge
|
|||
|
|
}
|
|||
|
|
if params.ID == 0 {
|
|||
|
|
params.ID = s.NextID()
|
|||
|
|
}
|
|||
|
|
err := s.InTx(ctx, func(tx *sql.Tx) error {
|
|||
|
|
var count int
|
|||
|
|
if err := tx.QueryRowContext(ctx,
|
|||
|
|
`SELECT COUNT(*) FROM guild_templates WHERE owner_id = ?`, mustID(params.OwnerID),
|
|||
|
|
).Scan(&count); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if count >= MaxUserGuildTemplates {
|
|||
|
|
return ErrTemplateLimitReached
|
|||
|
|
}
|
|||
|
|
_, err := tx.ExecContext(ctx, `
|
|||
|
|
INSERT INTO guild_templates (id, owner_id, name, description, structure_json, source_guild_id, created_at)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|||
|
|
mustID(params.ID), mustID(params.OwnerID), params.Name, params.Description,
|
|||
|
|
string(params.Structure), nullableID(params.SourceGuildID), s.Now())
|
|||
|
|
return err
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, mapError(err)
|
|||
|
|
}
|
|||
|
|
return s.GetUserGuildTemplate(ctx, params.ID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListUserGuildTemplates перечисляет личные шаблоны владельца: свежие первыми
|
|||
|
|
// (индекс guild_templates_owner_idx).
|
|||
|
|
func (s *Store) ListUserGuildTemplates(ctx context.Context, ownerID uint64) ([]UserGuildTemplate, error) {
|
|||
|
|
rows, err := s.reader.QueryContext(ctx,
|
|||
|
|
`SELECT `+userGuildTemplateColumns+` FROM guild_templates
|
|||
|
|
WHERE owner_id = ? ORDER BY id DESC`, mustID(ownerID))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
|
|||
|
|
templates := make([]UserGuildTemplate, 0, 8)
|
|||
|
|
for rows.Next() {
|
|||
|
|
template, err := scanUserGuildTemplate(rows)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
templates = append(templates, *template)
|
|||
|
|
}
|
|||
|
|
return templates, rows.Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserGuildTemplate отдаёт шаблон по идентификатору. Приватность проверяет
|
|||
|
|
// вызывающий код сравнением OwnerID с текущим пользователем: чужой шаблон
|
|||
|
|
// должен выглядеть как отсутствующий, а не подтверждать своё существование.
|
|||
|
|
func (s *Store) GetUserGuildTemplate(ctx context.Context, id uint64) (*UserGuildTemplate, error) {
|
|||
|
|
return scanUserGuildTemplate(s.reader.QueryRowContext(ctx,
|
|||
|
|
`SELECT `+userGuildTemplateColumns+` FROM guild_templates WHERE id = ?`, mustID(id)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteUserGuildTemplate удаляет шаблон владельца; false — шаблона нет или он
|
|||
|
|
// принадлежит другому пользователю (существование не подтверждаем).
|
|||
|
|
func (s *Store) DeleteUserGuildTemplate(ctx context.Context, id, ownerID uint64) (bool, error) {
|
|||
|
|
result, err := s.writer.ExecContext(ctx,
|
|||
|
|
`DELETE FROM guild_templates WHERE id = ? AND owner_id = ?`, mustID(id), mustID(ownerID))
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
affected, err := result.RowsAffected()
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
return affected > 0, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CountUserGuildTemplates считает личные шаблоны владельца — для проверки
|
|||
|
|
// лимита перед сохранением.
|
|||
|
|
func (s *Store) CountUserGuildTemplates(ctx context.Context, ownerID uint64) (int, error) {
|
|||
|
|
var count int
|
|||
|
|
err := s.reader.QueryRowContext(ctx,
|
|||
|
|
`SELECT COUNT(*) FROM guild_templates WHERE owner_id = ?`, mustID(ownerID)).Scan(&count)
|
|||
|
|
return count, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func scanUserGuildTemplate(scanner interface{ Scan(...any) error }) (*UserGuildTemplate, error) {
|
|||
|
|
var (
|
|||
|
|
template UserGuildTemplate
|
|||
|
|
structure string
|
|||
|
|
sourceGuildID sql.NullInt64
|
|||
|
|
createdAt string
|
|||
|
|
)
|
|||
|
|
err := scanner.Scan(&template.ID, &template.OwnerID, &template.Name, &template.Description,
|
|||
|
|
&structure, &sourceGuildID, &createdAt)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, mapError(err)
|
|||
|
|
}
|
|||
|
|
template.Structure = json.RawMessage(structure)
|
|||
|
|
template.SourceGuildID = optionalID(sourceGuildID)
|
|||
|
|
template.CreatedAt = parseTimestamp(createdAt)
|
|||
|
|
return &template, nil
|
|||
|
|
}
|