Files
glchat/internal/store/guild_templates.go
T
grendervill 605b2038f1 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 и категорию по имени.
2026-09-26 16:48:01 +03:00

162 lines
6.4 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"
"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
}