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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/database"
|
||||
)
|
||||
|
||||
// newUserGuildTemplateTestStore открывает временную БД со всеми миграциями и
|
||||
// заводит двух пользователей: шаблоны ссылаются на владельца внешним ключом,
|
||||
// а приватность проверяется на втором аккаунте.
|
||||
func newUserGuildTemplateTestStore(t *testing.T) (*Store, uint64, uint64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("открыть тестовую БД: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("закрыть тестовую БД: %v", err)
|
||||
}
|
||||
})
|
||||
st := New(db)
|
||||
owner, err := st.CreateUser(ctx, CreateUserParams{
|
||||
Username: "tpl_owner",
|
||||
EmailEnc: "enc-owner",
|
||||
EmailIndex: "idx-owner",
|
||||
PasswordHash: "hash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать владельца: %v", err)
|
||||
}
|
||||
other, err := st.CreateUser(ctx, CreateUserParams{
|
||||
Username: "tpl_other",
|
||||
EmailEnc: "enc-other",
|
||||
EmailIndex: "idx-other",
|
||||
PasswordHash: "hash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать второго пользователя: %v", err)
|
||||
}
|
||||
return st, owner.ID, other.ID
|
||||
}
|
||||
|
||||
func TestUserGuildTemplateLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, ownerID, _ := newUserGuildTemplateTestStore(t)
|
||||
|
||||
guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Источник", OwnerID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGuild: %v", err)
|
||||
}
|
||||
structure := json.RawMessage(`{"roles":[{"name":"Модератор","color":16711680,"permissions":"KICK_MEMBERS","position":50,"hoist":true,"mentionable":true}],"channels":[{"name":"Информация","type":"category","position":0}]}`)
|
||||
|
||||
created, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: ownerID,
|
||||
Name: "Мой шаблон",
|
||||
Description: "Структура",
|
||||
Structure: structure,
|
||||
SourceGuildID: &guild.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserGuildTemplate: %v", err)
|
||||
}
|
||||
if created.ID == 0 || created.OwnerID != ownerID || created.Name != "Мой шаблон" {
|
||||
t.Fatalf("неожиданный шаблон: %+v", created)
|
||||
}
|
||||
if created.SourceGuildID == nil || *created.SourceGuildID != guild.ID {
|
||||
t.Fatalf("source_guild_id = %v, ожидался %d", created.SourceGuildID, guild.ID)
|
||||
}
|
||||
if string(created.Structure) != string(structure) {
|
||||
t.Fatalf("structure_json = %s", created.Structure)
|
||||
}
|
||||
if created.CreatedAt.IsZero() {
|
||||
t.Fatal("created_at не заполнен")
|
||||
}
|
||||
|
||||
fetched, err := st.GetUserGuildTemplate(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserGuildTemplate: %v", err)
|
||||
}
|
||||
if fetched.Name != created.Name || fetched.Description != created.Description {
|
||||
t.Fatalf("прочитан другой шаблон: %+v", fetched)
|
||||
}
|
||||
|
||||
second, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: ownerID,
|
||||
Name: "Второй",
|
||||
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserGuildTemplate (второй): %v", err)
|
||||
}
|
||||
|
||||
list, err := st.ListUserGuildTemplates(ctx, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserGuildTemplates: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("шаблонов %d, ожидалось 2", len(list))
|
||||
}
|
||||
// Свежие — первыми: список сортируется по id DESC.
|
||||
if list[0].ID != second.ID || list[1].ID != created.ID {
|
||||
t.Fatalf("порядок шаблонов неверен: %d, %d", list[0].ID, list[1].ID)
|
||||
}
|
||||
|
||||
count, err := st.CountUserGuildTemplates(ctx, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("CountUserGuildTemplates = %d, ожидалось 2", count)
|
||||
}
|
||||
|
||||
deleted, err := st.DeleteUserGuildTemplate(ctx, created.ID, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserGuildTemplate: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatal("свой шаблон не удалён")
|
||||
}
|
||||
if _, err := st.GetUserGuildTemplate(ctx, created.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("удалённый шаблон читается: %v", err)
|
||||
}
|
||||
deleted, err = st.DeleteUserGuildTemplate(ctx, created.ID, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("повторное удаление: %v", err)
|
||||
}
|
||||
if deleted {
|
||||
t.Fatal("повторное удаление вернуло true")
|
||||
}
|
||||
}
|
||||
|
||||
// Приватность: чужой шаблон не виден в списке и не удаляется, его владелец
|
||||
// продолжает им пользоваться.
|
||||
func TestUserGuildTemplatePrivacy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, ownerID, otherID := newUserGuildTemplateTestStore(t)
|
||||
|
||||
created, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: ownerID,
|
||||
Name: "Секретный",
|
||||
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserGuildTemplate: %v", err)
|
||||
}
|
||||
|
||||
list, err := st.ListUserGuildTemplates(ctx, otherID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserGuildTemplates: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("чужой шаблон виден в списке: %+v", list)
|
||||
}
|
||||
count, err := st.CountUserGuildTemplates(ctx, otherID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("чужой шаблон посчитан: %d", count)
|
||||
}
|
||||
deleted, err := st.DeleteUserGuildTemplate(ctx, created.ID, otherID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserGuildTemplate: %v", err)
|
||||
}
|
||||
if deleted {
|
||||
t.Fatal("чужой шаблон удалён")
|
||||
}
|
||||
if _, err := st.GetUserGuildTemplate(ctx, created.ID); err != nil {
|
||||
t.Fatalf("шаблон владельца пропал: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGuildTemplateLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, ownerID, otherID := newUserGuildTemplateTestStore(t)
|
||||
|
||||
for i := 0; i < MaxUserGuildTemplates; i++ {
|
||||
if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: ownerID,
|
||||
Name: "Шаблон",
|
||||
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("шаблон %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
_, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: ownerID,
|
||||
Name: "Лишний",
|
||||
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||
})
|
||||
if !errors.Is(err, ErrTemplateLimitReached) {
|
||||
t.Fatalf("превышение лимита = %v, ожидалось ErrTemplateLimitReached", err)
|
||||
}
|
||||
count, err := st.CountUserGuildTemplates(ctx, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||
}
|
||||
if count != MaxUserGuildTemplates {
|
||||
t.Fatalf("после отказа шаблонов %d, ожидалось %d", count, MaxUserGuildTemplates)
|
||||
}
|
||||
// Лимит считается по владельцу: у второго пользователя место свободно.
|
||||
if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: otherID,
|
||||
Name: "Свой",
|
||||
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("шаблон второго пользователя: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGuildTemplateTooLarge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, ownerID, _ := newUserGuildTemplateTestStore(t)
|
||||
|
||||
oversized := json.RawMessage(`{"note":"` + strings.Repeat("x", MaxUserGuildTemplateBytes) + `"}`)
|
||||
if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||
OwnerID: ownerID,
|
||||
Name: "Большой",
|
||||
Structure: oversized,
|
||||
}); !errors.Is(err, ErrTemplateTooLarge) {
|
||||
t.Fatalf("слишком большая структура = %v, ожидалось ErrTemplateTooLarge", err)
|
||||
}
|
||||
count, err := st.CountUserGuildTemplates(ctx, ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("отклонённый шаблон сохранён: %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user