创建插件
最小可用插件
// plugins/my-plugin/plugin.go
package myplugin
import (
"context"
"github.com/gin-gonic/gin"
"go-press/core"
"go-press/core/hook"
"go-press/core/plugin"
)
type MyPlugin struct {
engine *core.Engine
hookHandles []hook.Handle // 用于 Deactivate 时干净摘除
}
func New() *MyPlugin { return &MyPlugin{} }
func (p *MyPlugin) Name() string { return "my-plugin" }
func (p *MyPlugin) Version() string { return "1.0.0" }
func (p *MyPlugin) Description() string { return "My custom plugin" }
func (p *MyPlugin) Activate(app plugin.App) {
e := app.(*core.Engine)
p.engine = e
p.hookHandles = p.hookHandles[:0]
// 注册插件自定义表(可选)
core.RegisterPluginTable("my-plugin", "records")
// 通过 Hook 注入功能
p.hookHandles = append(p.hookHandles,
e.Hooks.AddAction("routes.register", func(_ context.Context, args ...interface{}) {
r := args[0].(*gin.Engine)
r.GET("/my-endpoint", myHandler)
}, 10),
)
}
func (p *MyPlugin) Deactivate(_ plugin.App) {
for _, h := range p.hookHandles {
p.engine.Hooks.RemoveAction(h)
p.engine.Hooks.RemoveFilter(h)
}
p.hookHandles = p.hookHandles[:0]
}
// plugins/my-plugin/register.go
package myplugin
import "go-press/core"
func init() {
core.RegisterPlugin("my-plugin", func(engine *core.Engine) {
engine.LoadPlugin(New())
})
}
不需要手动改 cmd/server/main.go。把目录拖到 plugins/,确保根目录同时有 plugin.toml 和至少一个非 test .go 文件,然后重新执行 gopress serve。autoload 包会被重新生成,新插件的 init() 在启动时自动调用 core.RegisterPlugin 完成注册。详见 安装与运行。
插件元数据
每个插件根目录必须有 plugin.toml——它既是 gopress 自动发现的标记(缺它则 plugins/<name>/ 目录会被忽略),也作为后台插件管理 UI 与后续插件注册表的元信息来源。最小 schema:
[plugin]
slug = "my-plugin"
name = "My Plugin"
version = "1.0.0"
description = "插件简介"
author = "Me"
slug 是 Name() 返回的运行时标识,也是主题声明依赖时使用的值。
version 必须是合法 semver,并且是唯一版本来源:插件应嵌入并解析
plugin.toml,Version() 返回解析结果,不要再在 Go 中手写一份版本号。
import (
_ "embed"
"go-press/core/plugin"
)
//go:embed plugin.toml
var pluginTOML string
var pluginMeta = plugin.ParseMetaString(pluginTOML)
插件数据表
插件自有表用 dbprefix.PluginTable(slug, name) 命名,并通过
core.RegisterPluginTable 登记归属。迁移模型时请调用 core/dbschema 的
dbschema.AutoMigrate(db, models...),不要直接调用 db.AutoMigrate:
uniqueIndex:idx_my_plugin_key 这类固定索引名在 PostgreSQL 的一个 schema 内唯一,
多个站点共用 schema 时只有第一个站点能拿到;dbschema.AutoMigrate 会在缺失的
站点表上补建等价的本站索引。详见数据库表前缀。
Plugin 接口可选扩展
// SettingsProvider — 在后台插件管理中显示设置页面
func (p *MyPlugin) SettingsTemplatePath() string {
return "plugins/my-plugin/templates/admin/settings.tmpl"
}
// SettingsDataProvider — 向设置页模板注入自定义数据
func (p *MyPlugin) SettingsData() map[string]interface{} {
return map[string]interface{}{"MyItems": items}
}
// SettingsSaveProvider — 在设置保存后执行自定义逻辑
func (p *MyPlugin) OnSettingsSave(settings map[string]string) {
// 同步设置到插件自有表...
}
// SettingsValidateProvider — Core 落库任何选项之前校验完整提交值
// 此方法必须无副作用;返回错误会拒绝整次保存,避免部分更新。
func (p *MyPlugin) ValidateSettings(settings map[string]string) error {
return nil
}
// LogoProvider — 在后台「插件管理」卡片上显示插件图标
//
//go:embed static/logo.svg
var logoSVG string
func (p *MyPlugin) LogoSVG() string { return logoSVG }
后台会先检查该插件设置资源的 update RBAC 权限,再执行校验和持久化。若插件需要更细的设置权限,应实现 SettingsAuthorizationProvider 复用 core 的路由与权限检查,不要自行注册只验证登录状态的降级路由。
LogoProvider 与主题一致:把一个 viewBox="0 0 48 48" 的方形 SVG 嵌入插件(//go:embed),LogoSVG() 返回其内容即可。core 会先用 content.SanitizeSVG 清洗再内联进后台,所以第三方插件的 logo 也安全;返回 "" 则卡片不显示图标。
注册请求级内容与 Taxonomy 过滤
如果你的插件需要让前后台内容查询自动按某条件过滤(多语言、可见性、草稿预览等):
// 在 middleware.early hook 中注册请求级内容过滤
e.Hooks.AddAction("middleware.early", func(_ context.Context, args ...interface{}) {
r := args[0].(*gin.Engine)
r.Use(func(c *gin.Context) {
// 通过 core API 注册过滤条件
content.AddContentScope(c, func(db *gorm.DB) *gorm.DB {
return db.Where("visible = ?", true)
})
c.Next()
})
}, 5)
// 主题自动获得过滤后的查询结果,无需任何适配代码
如果扩展拥有按语言、租户等变体隔离的 Category/Tag identity,还应注册
taxonomy.Scope,并让 taxonomy 写入统一经过 taxonomy.CommandService;
不要给 core 添加特定语言或租户分支。详见
内容与 Taxonomy Scope API。
身份 Provider 插件
外部身份插件必须通过 core 提供的 plugin.PublicAuthHost 契约接入。插件负责 Provider 协议、回调验证、密钥及 Provider 专属设置;GoPress 用户、身份绑定、登录会话、开放注册策略和账号绑定策略只能由 core 管理。
插件验证完外部响应后,把归一化的 user.VerifiedIdentity 交给 core。插件不能直接创建用户或会话,也不能让主题依赖 Google、MetaMask 等 Provider SDK 类型。登录入口由 Provider 注册表统一发布,主题只通过通用的 loginProviders 模板 helper 渲染。
完整接口、Google OIDC 与 MetaMask SIWE 示例、Provider 图标、路由及 RBAC 约束见前台账号与外部身份登录。
热拔插要点
GoPress 支持插件运行时完全热拔插。要做到这一点,插件实现必须遵守:
AddAction/AddFilter返回的Handle必须保存 — 插件结构体里维护一个hookHandles []hook.Handle,每次注册都 append 进去Deactivate中按 handle 摘除全部 — 调RemoveAction+RemoveFilter(不知道是 action 还是 filter 时两个都调,方法对零值或不匹配的 handle 是 no-op)- Gin Router 会在插件启停后重建 — 当前有效插件的
middleware.early/routes.register会重新应用;长生命周期或异步插件仍应维护运行态开关,覆盖正在处理的旧请求 - Sitemap transformer / 其他对称 Add/Remove API — 同样保存 handle,对称摘除
- 后台 goroutine 必须可停止 —
Deactivate中停止插件自己的 ticker / 循环;同时实现可选接口plugin.Stopper,进程停机时 core 会调用Stop(ctx)(不经过DeactivationGuard,停机不会被“禁止停用”策略阻塞)。Stop应在ctx截止时间内返回,并且可以重复调用 - 周期任务优先用 core 调度器 —
e.Sched.AddJob(name, interval, fn)在运行期调用也会立即生效,返回的worker.JobHandle在Deactivate中交给e.Sched.RemoveJob;停机时调度器由 core 统一停止
生命周期并发约定
- core 串行执行插件启用、停用和主题切换;同一插件重复启用是幂等的,
Activate不会被二次调用 Activate/Deactivate内不要调用PluginManager.Activate/Deactivate(会死锁);PluginManager.IsActive无锁、可在每个请求的热路径上安全调用- 停机开始后
Workers.Submit/SubmitFunc返回worker.ErrPoolClosed,异步副作用应处理该错误;任务 panic 会被 core 捕获记录,但仍应在任务内返回明确错误
type MyPlugin struct {
engine *core.Engine
job worker.JobHandle
stop chan struct{}
done chan struct{}
}
func (p *MyPlugin) Activate(app plugin.App) {
p.engine = app.(*core.Engine)
// 周期任务:交给 core 调度器
p.job = p.engine.Sched.AddJob("my-plugin:cleanup", time.Hour, p.cleanup)
// 必须自管的长循环:保留 stop/done 以便停用与停机
p.stop, p.done = make(chan struct{}), make(chan struct{})
go p.loop(p.stop, p.done)
}
func (p *MyPlugin) Deactivate(_ plugin.App) {
p.engine.Sched.RemoveJob(p.job)
p.stopLoop()
}
// Stop 实现 plugin.Stopper,进程停机时调用。
func (p *MyPlugin) Stop(ctx context.Context) error {
finished := make(chan struct{})
go func() { p.stopLoop(); close(finished) }()
select {
case <-finished:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
stopLoop 只需在 stop 非空时关闭它、等待 done 并置空;core 串行调用
Deactivate 与 Stop,不会并发执行。
参考 multilang 插件 是完整的热拔插实现样板。
内置 Hook 速查
详细列表见 Hook 系统。常用:
| Hook | 类型 | 用途 |
|---|---|---|
engine.init |
action | Bootstrap 完成后 |
middleware.early |
action | 注册中间件(页面缓存之前) |
routes.register |
action | 注册路由(admin 之后、catch-all 之前) |
options.bulk_updated |
action | 批量保存设置后失效缓存 |
theme.head.end |
filter | </head> 前 HTML 插槽 |
theme.body.open |
filter | <body> 后立即 HTML 插槽 |
theme.footer.end |
filter | </body> 前 HTML 插槽 |
header.nav.after |
filter | 主题导航尾部 HTML 插槽 |
menu.location.resolve |
filter | 菜单按位置返回前的最终 transform |
admin.content_form.fields |
filter | 内容编辑页 meta box 插槽 |
admin.content.saved |
action | 内容保存后副作用 |
admin.dashboard.widgets |
filter | Dashboard 统计组件插槽,value 为 template.HTML |
seo.content.meta |
filter | 单页内容 SEOMeta 渲染前 |
