flycms
Партнёрам

Как написать плагин

Плагин — папка с Go-файлами. Магазин читает её интерпретатором на лету: положили папку, нажали «Перечитать» в админке — раздел появился. Компилировать, собирать образ и перезапускать ничего не нужно. Контракт между магазином и плагином — открытый пакет github.com/orgflycms/sdk.

Что такое плагин

Папка называется полным именем плагина: пространство.имя, например vitams.promo. Внутри — файлы одного Go-пакета, названного коротким именем (promo), карточка plugin.json и README.md. Подпапок нет.

vitams.promo/
├── promo.go       package promo, func Init(api sdk.API) error
├── plugin.json    карточка: name, title, description, version
└── README.md      для людей: что делает, как настроить

Точка входа одна — Init. Магазин зовёт её один раз при загрузке; в ней плагин регистрирует всё, что умеет: пункты меню, адреса страниц, перехватчик корзины, свои таблицы. Дальше магазин сам вызывает то, что зарегистрировано.

Плагину доступна стандартная библиотека Go и пакет sdk. Сторонних модулей нет и не будет: их некому скачать и собрать. Закрыты os/exec, syscall, unsafe, plugin и всё из net/*, кроме net/http и net/url. Плагин с таким импортом не загрузится, ошибка будет видна в списке плагинов.

Пространство имён

При регистрации в кабинете партнёра вы выбираете пространство имён: строчные латинские буквы, цифры и _, от двух до 21 знака. Занятость проверяется прямо в форме. Пространство становится префиксом всех ваших плагинов: партнёр vitams публикует vitams.promo, vitams.delivery и так далее. Так имена не пересекаются между партнёрами, а покупатель видит, чей плагин ставит.

В форме заявки вы вводите только короткое имя — promo; префикс подставится сам. Короткое имя — это имя Go-пакета в архиве. После первой публикации имя не меняется: на нём папки в магазинах.

Минимальный плагин

Пункт в меню админки и страница. С этого начинается любой плагин.

// vitams.hello/hello.go
package hello

import (
	"html/template"
	"net/http"

	"github.com/orgflycms/sdk"
)

func Init(api sdk.API) error {
	api.Menu("Привет", "/")
	api.Handle("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		body := `<div class="card p-5">Плагин «` + template.HTMLEscapeString(api.Name()) + `» работает.</div>`
		api.Page(w, r, "Привет", template.HTML(body))
	})
	return nil
}
// vitams.hello/plugin.json
{
  "name": "vitams.hello",
  "title": "Привет",
  "description": "Пункт в меню и страница в админке.",
  "version": "1.0.0"
}

Положите папку vitams.hello в папку плагинов магазина и нажмите «Перечитать» — в меню админки появится «Привет».

Что даёт sdk.API

Всё, что магазин даёт плагину, — один интерфейс. Он только расширяется: метод нельзя убрать или переименовать, поэтому плагин, написанный сегодня, будет работать и после обновления магазина.

МетодЧто делает
Name() stringПолное имя плагина, оно же имя папки: vitams.promo.
Menu(label, href)Пункт в меню админки. href — адрес внутри плагина от «/».
Handle(pattern, h)Обработчик по шаблону http.ServeMux: «GET /{$}», «POST /new», «POST /{id}/delete». Адреса живут под /admin/x/<имя>/ и открыты только вошедшему в админку.
Path(href) stringПолный адрес маршрута для ссылок и форм: Path("/new") → «/admin/x/vitams.promo/new».
Page(w, r, title, body)Страница в обёртке админки: меню, шапка, подвал. body — готовая разметка.
DB() DBБаза магазина: Exec и Query, строки приходят картами map[string]any. Плейсхолдеры $1, $2.
OnCart(fn)Перехватчик корзины: зовётся при каждом расчёте — на странице корзины и при оформлении заказа.
Money(kopecks) stringКопейки в строку для покупателя: 123456 → «1 234,56 ₽».

Комментарий к каждому методу — в sdk.go. Разметку страниц плагин собирает сам, удобнее всего html/template из стандартной библиотеки. Классы card, btn, input, field, label, data-table, notice — те же, что в админке, они не меняются.

Своя таблица и форма

Таблицы плагин заводит сам в Init через create table if not exists. Имя таблицы начинается с имени плагина: promo_codes, а не codes. Чужие таблицы плагин не трогает — их схема меняется без предупреждения.

package notes

import (
	"bytes"
	"context"
	"fmt"
	"html/template"
	"net/http"

	"github.com/orgflycms/sdk"
)

var page = template.Must(template.New("").Parse(`
<form method="post" action="{{.New}}" class="card p-5 flex gap-3">
	<input name="text" class="input" placeholder="Заметка" required>
	<button class="btn">Добавить</button>
</form>
<div class="card mt-5">
	{{range .Rows}}<div class="panel-row">{{.text}}</div>{{end}}
</div>`))

func Init(api sdk.API) error {
	if err := api.DB().Exec(context.Background(), `create table if not exists notes_items (
		id   bigserial primary key,
		text text not null
	)`); err != nil {
		return fmt.Errorf("таблица заметок: %w", err)
	}

	api.Menu("Заметки", "/")
	api.Handle("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		rows, err := api.DB().Query(r.Context(), "select text from notes_items order by id desc")
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		var buf bytes.Buffer
		page.Execute(&buf, map[string]any{"New": api.Path("/new"), "Rows": rows})
		api.Page(w, r, "Заметки", template.HTML(buf.String()))
	})
	api.Handle("POST /new", func(w http.ResponseWriter, r *http.Request) {
		if err := api.DB().Exec(r.Context(), "insert into notes_items (text) values ($1)", r.FormValue("text")); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		http.Redirect(w, r, api.Path("/"), http.StatusSeeOther)
	})
	return nil
}

Ошибка в Init — плагин не загружается, текст ошибки виден в списке плагинов. Паника в обработчике — 500 на этот запрос, магазин продолжает работать.

Скидка в корзине

Скидки в магазине дают только плагины. Ядро считает сумму товаров и зовёт перехватчики; перехватчик правит Discount и Notes, остальное только читает. Суммы в копейках. Итог ниже нуля не бывает, даже если два плагина дадут больше, чем стоит корзина.

api.OnCart(func(ctx context.Context, c *sdk.Cart) {
	// Промокод ввёл покупатель; пусто — не вводил.
	if c.Code == "" {
		return
	}
	rows, err := api.DB().Query(ctx,
		"select discount, min_total from promo_codes where code = $1 and active", c.Code)
	if err != nil || len(rows) == 0 {
		return
	}
	discount, minTotal := rows[0]["discount"].(int64), rows[0]["min_total"].(int64)
	if c.Subtotal < minTotal {
		c.Notes = append(c.Notes, "Промокод "+c.Code+" действует от "+api.Money(minTotal))
		return
	}
	c.Discount += discount
	c.Notes = append(c.Notes, "Промокод "+c.Code+": −"+api.Money(discount))
})

Что лежит в корзине: Items с товаром, ценой и количеством, Code, Subtotal, Discount, Notes; Total() — к оплате. Полный пример с разделом в админке — плагин «Промокоды», он же образец для модерации.

plugin.json

Карточка плагина — четыре поля. name — полное имя с пространством, version — semver, три числа через точку.

{
  "name": "vitams.promo",
  "title": "Промокоды",
  "description": "Скидка фиксированной суммой при корзине от порога.",
  "version": "1.2.0"
}

Проверить у себя

  1. Склонируйте sdk — в нём go.mod для подсказок редактора. Положите папку плагина рядом с example/ и выполните go build ./...: компилируется — значит, и в магазине прочитается.
  2. Положите папку в папку плагинов своего магазина — plugins/ рядом с docker-compose.yml — и нажмите «Перечитать» в разделе «Плагины».
  3. Не загрузился — текст ошибки там же, в списке. Поправили файл — снова «Перечитать».

Опубликовать

В кабинете партнёра — «Новый плагин»: короткое имя, название, описание, цена (0 — бесплатный), до пяти картинок и архив с кодом. Что проверяется до модерации:

  • архив .tar.gz или .zip до 1 МБ, в корне только *.go без _test.go, README.md и plugin.json; подпапок нет;
  • имя пакета равно короткому имени плагина, есть func Init(;
  • версия — semver и больше предыдущей опубликованной;
  • картинки — jpeg, png или webp до 2 МБ.

Модератор читает код и одобряет или возвращает с замечанием — оно видно в кабинете. Любая правка опубликованного плагина — та же форма и новая заявка: карточка в каталоге показывает последнее одобренное, у плагина не больше одной заявки на модерации. Правка без архива оставляет версию и код прежними.

Магазины ставят плагин кнопкой из своей админки: платный сначала покупается, потом скачивается архив и папка vitams.promo появляется у них без перезапуска.

Правила и ограничения

  • Деньги — целые копейки: int64, вывод через api.Money.
  • Адреса плагина закрыты как вся админка: покупатель их не видит. Витрину плагин пока не меняет — только корзину.
  • Сеть: только net/http и net/url. Магазин сам ничего наружу не отправляет, плагин — по своему усмотрению и с ведома владельца в описании.
  • Тексты интерфейса — по-русски.
  • Один плагин — одна папка и один пакет. Файлов сколько угодно, лишь бы в корне.