feat(templates): личные шаблоны серверов в хранилище и API (Фаза 7)

AGENT.md 7.3: свои шаблоны серверов — сохранить структуру сервера
(роли с правами и цветом, категории и комнаты) и создать по ней новый.
Миграция 00023 создаёт guild_templates (structure_json, source_guild_id,
каскад по владельцу) с индексом владельца.

- store: CRUD личных шаблонов с фильтром по owner_id и защитой лимитов
  (20 шаблонов, 16 КиБ structure_json);
- server: POST /guild-templates, GET /guild-templates/mine и
  DELETE /guild-templates/{id} (MANAGE_GUILD, чужой шаблон — 404),
  создание сервера по template_id=personal:<id> (аудит template=personal:<id>);
- структура шаблона повторяет API ролей (права строкой) и хранит
  slowmode_seconds, user_limit и категорию по имени.
This commit is contained in:
2026-09-26 16:45:10 +03:00
parent 8696d85514
commit 605b2038f1
7 changed files with 1552 additions and 22 deletions
+161
View File
@@ -0,0 +1,161 @@
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
}