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 }