gettopic 编译期插件机制
编译期静态插件(源码内置 + 重新构建):自带 RPC/表/迁移/菜单/授权声明/可选 job;显式 Register() 注册;启动期 ValidateAll 强校验;前端单一动态路由按 menu.component 加载页。有意不用 WASM/.so/go-plugin/init() 魔法(理由见设计裁决)。
加一个插件(脚手架,推荐)
task new-plugin -- <name> [field:type,...] # 例: task new-plugin -- blog title:string,views:int(shop 已作为内置示例)
task gen # 生成 proto Go/TS + connect-query hook
# 填 internal/plugin/impl/<name>/service.go 业务逻辑(脚手架已给 CRUD stub)
task build && 重启
name 须匹配 ^[a-z][a-z0-9_]{0,30}$。字段类型:string|int|int32|bool|float;id/name 为内置字段,传入会被忽略(避免重复)。
- 生成器产出:
proto/zerx/v1/<name>.proto、internal/plugin/impl/<name>/{plugin,model,service}.go、<name>_teardown.sql、web/src/plugin-components/<name>/<Pascal>.tsx,并在 internal/plugins/all.go 两处锚点插行(// plugin-import-anchor / // plugin-register-anchor)后 gofmt。
手动新增插件的核心改动 = 仅 all.go 两行
装配点(server.New / main.go)各只接一次钩子,之后永不再改。新增插件唯一核心改动是 internal/plugins/all.go:import 块加一行 + Register() 体加一行。
Plugin 契约(internal/plugin/plugin.go)
实现 plugin.Plugin 接口(示例见 internal/plugin/impl/shop/plugin.go):
Name() 小写蛇形,全局唯一。
Services() 本插件 connectRPC service 全名(如 zerx.v1.ShopProductService)——ValidateAll 据此校验 public/selfServe procedure 归属。
Migrations() gormigrate 迁移,ID 须 plg_<name>_ 前缀;TableNames() 表名须 plg_<name>_ 前缀。
SeedMenus() 菜单 Name 须 plg_<name> 前缀、Path 须 /p/<name> 前缀;Component 为 glob 相对标识(如 shop/Shop);UserVisible 控制是否默认授 user 角色 RoleMenu。
PublicProcedures()/SelfServeProcedures() 必属本插件 service(否则启动 fatal,堵提权)。
RegisterHandlers(reg, deps) 用 deps.Opts(完整拦截器链)注册 handler;deps 含 DB/Opts/Enforcer/Media/Logger。
JobHandlers() 可选,key 须 <name>_ 前缀;类型是 plugin.JobHandler{Run,Description}(不引 jobs.Descriptor,断开循环引用)。
启动期校验(ValidateAll,失败 fatal)
main.go 在 plugins.Register() 后、任何 DB 操作前调用,校验:名字正则+唯一、迁移ID前缀、表名前缀、job key 前缀、Service 名以插件 PascalCase 前缀且全局唯一、public/selfServe procedure 必属本插件 service、菜单 Name/Path 命名空间且与核心 seed 及其他插件无冲突、public page 路径 /pub/<name> 命名空间。
- 提权防线(安全核):
Services() 每项 short name 必须以插件 PascalCase 名开头(shop → ShopProductService),且一个 service 全局只能属一个插件。这闸死了"插件声明拥有核心 service(如 zerx.v1.UserService)再把其 procedure 洗进 public/selfServe map"的提权通道——否则 procedure-ownership 校验会被架空。
- 已知边界(信任模型,见设计 Assumption A):
TableNames() 是插件自声明列表,ValidateAll 校验的是该声明的前缀,不内省迁移实际建的表。插件被定位为同仓库可重建二进制的可信协作方;若迁移里 AutoMigrate 一张非前缀(甚至核心)表,ValidateAll 不拦——靠 code review / CI 把关。务必让 model 的 TableName() 返回 plg_<name>_* 且与 TableNames() 一致(示例 shop 已如此)。
授权(Casbin 唯一权威,零拦截器改动)
插件 handler 经同一 opts 注册 → 自动受 Casbin 保护。插件 procedure 默认仅 admin 可用(admin bypass)。让非 admin 角色用上插件页需管理员在 UI 配两类:RoleMenu(菜单可见)+ RolePermission/casbin_rule(接口放行)。插件不自行 seed casbin_rule 提权。细节 → skill://zerx-authz。
前端动态页(承重机制)
- 页放
web/src/plugin-components/<name>/<Component>.tsx(default export)。
- 单一 splat 路由
web/src/routes/_authed/p.$.tsx(参数 _splat):import.meta.glob('/src/plugin-components/**/*.tsx') 编译期收集;用 path==='/p/'+_splat 在用户菜单树找到 menu.component,拼 key /src/plugin-components/${component}.tsx,React.lazy 加载;找不到渲染 NotFound。splat 同时支持扁平 /p/<name> 与分组子页 /p/<name>/<sub>。
- 分组菜单(一级带多个二级):
SeedMenus 返回一个 group node(Path: "")+ Children 多个叶子(各自 Path: "/p/<name>/<sub>"、Component: "<name>/<Sub>");参考 shop 示例(商城分组 → 商品/分类)。validate 允许叶子 path 为 /p/<name> 或 /p/<name>/...。
- 侧边栏(
route.tsx):/p/ 前缀菜单走 <Link to="/p/$" params={{_splat: menu.path.slice(3)}}>,非插件菜单保持 <Link to={menu.path}>。
- i18n(自包含,glob 自动收集):菜单 Title / 页面文案用 key
plg.<name>.<k>,翻译放插件目录内 web/src/plugin-components/<name>/i18n.ts(default export { en:{...}, zh:{...} },en/zh key 同步)。web/src/lib/i18n.tsx 用 import.meta.glob('/src/plugin-components/**/i18n.ts', { eager:true }) 编译期收集,按目录名合并进 plg.<name> 命名空间——与组件 glob 同构。故 ZIP 安装的插件翻译随 web/ 解压落回原位、重建即生效,无需改 i18n.tsx、无需改 installer(手动新增插件同理,生成器已产出 i18n.ts 模板)。图标在 web/src/lib/menu-icons.ts 注册(未注册回退 CircleIcon)。
插件管理页(运行时启用/禁用)
- 后台「插件管理」页
web/src/routes/_authed/plugins.tsx(菜单 /plugins,Casbin 仅 admin)经 PluginService(proto/zerx/v1/plugin.proto)透出已编译插件清单 + 运行时启停。
- 编译期约束:install/uninstall 需重建二进制,UI 不做(仅文档化指引)。enable/disable 是运行时开关,无需重建。
- 运行时状态
internal/plugin/state.go(plugin.State,核心表 plugin_states,默认 enabled;SetEnabled 用 map upsert 以持久化 enabled=false——struct 写会被 default:true/零值跳过吞掉)。禁用插件三处 gate:(a) casbin_interceptor 在所有放行路径(含 admin)之前 procEnabled 拒绝 → CodeUnavailable;(b) MenuService.GetUserMenus 过滤 PluginNameOfMenu 命中的被禁菜单;(c) scheduler.wrap 在 fire time 经 SetHandlerEnabled 跳过被禁插件 job。三者由 main.go 注入同一 plugin.State(多副本驱动 postgres/mysql 经 StartReloader 周期重载,使一副本的启停传播到其他副本)。
- 数据保留:禁用只关闭菜单/接口/job,不动表、数据、迁移;启用即恢复对同一批数据的访问(
SetEnabled 仅写 plugin_states.enabled)。
- 菜单 reconcile:
syncMenus 对插件菜单(plg_ 前缀)同步 path/component/parent/title/icon/hidden(结构仍以 SeedMenus 为准);不覆盖 sort(admin 拖拽 / UpdateMenu 跨重启保留)。新插入的顶层插件菜单 Sort = dashboard.Sort+1,排在仪表盘下、系统/审计组之上(核心 system=10/audit=20 留空档)。核心菜单仍 insert-only。改插件菜单结构(如扁平页变分组子页)重启即生效,无需手动清库。
上传安装 / 卸载(GVA 式,脚手架机制)
零插件不变量(关键):internal/plugins/all.go 含 var _ = plugin.All sentinel —— 即使卸载最后一个插件、Register() 体清空,plugin 包 import 也不会变成 "imported and not used"。卸载任何插件(含最后一个)都不会让程序 build/run 失败。 installer 写 all.go 前用 go/parser 校验仍是合法 Go,拒绝写入坏文件。删 sentinel 会破坏此不变量。
编译期插件无法免重建热插拔(Go 限制)。「上传 zip 安装」= 把源码解压进仓库 + 改 all.go,装后重启生效(dev 由 air 自动重编重启;生产手动 task gen && task build 重新部署)。
- 打包:
task plugin-pack -- <name> → <name>.zip(plugin.json + proto/<name>.proto + impl/ + web/)。
- 安装:插件管理页「上传插件」→
PluginService.InstallPlugin(bytes package) → internal/plugin/installer 校验(manifest name 正则+非 Go 关键字、zip-slip 防护、≤256 文件 / ≤25MB、不覆盖已存在)→ 写盘 + all.go 插行(锚点在独立注释行,故卸载锚点插件也安全)→ dev 自动 task gen。
- 卸载后的中间态(重要):卸载只删磁盘源码+all.go 行,但当前运行的二进制仍编进了该插件,故
ListPlugins(返回 plugin.All())重启前仍列出它。后端检测 impl 目录已不在磁盘 → 置 PluginInfo.pending_removal=true,前端该行显「待重启移除」徽章并禁用启停/卸载按钮。dev 下 air 重编重启后 shop 从 registry 消失、该行自动不再出现;生产需 task build 重部。
- 卸载:每行垃圾桶按钮 →
UninstallPlugin(name, purge_data)。删源码 + all.go 行。purge_data=true(确认框点「确定」)走 teardownData:DROP 插件表 + 硬删(Unscoped())menus/grants/jobs/apis/casbin_rule/migrations/plugin_states——真正清空,同名重装从零开始;purge_data=false 保留数据。
- 安全门控:两接口 Casbin 仅 admin 且
config.UploadAllowed()(PLUGIN_UPLOAD_ENABLED 显式优先,否则仅非 prod)——上传源码 = RCE 级,生产默认关闭。ListPluginsResponse.upload_allowed 透给前端,关闭时隐藏上传/卸载按钮。请求体经 WithReadMaxBytes(25MB) 限流(connect 在 gate 前缓冲)。
- 注:已编译进进程的插件清单(
ListPlugins)在重启前不反映新装/已卸载插件——pending_restart 提示用户重启。
纯前台 / 公开页(匿名,官网类)
插件可服务无需登录的前台页(如官网、落地页),与后台菜单/API 并存:
- Plugin 接口
PublicPages() []PublicPage{Path,Component,Title} 声明,Path 必须 /pub/<name> 或 /pub/<name>/...(validate 校验)。
- 匿名接口
PluginService.ListPublicPages(在 server.go 的 public map,免认证)返回已启用插件的公开页;前端公开 splat 路由 web/src/routes/pub.$.tsx(顶层,不在 _authed 下,无 auth)据此按 /pub/... 匹配 → glob 加载组件。
- 禁用插件 →
ListPublicPages 不再返回其页 → /pub/<name> 渲染 NotFound(公开页随插件启停)。
- 组合能力:一个插件可同时有 后台菜单(
SeedMenus)+ 鉴权 API(默认 Casbin)+ 匿名 API(PublicProcedures)+ 匿名前台页(PublicPages)。示例见 shop 的 /pub/shop Landing。
生命周期:启用 / 禁用 / 卸载
- 启用:见上「加一个插件」。迁移/菜单/job 自动接入。
- 禁用(关键约束):
assertServicesRegistered 对已编译进 proto 描述符的 zerx.v1 service 强制要求挂载。仅删 all.go 的 Register 行而保留插件 proto/生成代码 → 启动 fatal。正确禁用 = 删 proto/zerx/v1/<name>.proto → task gen(清除已入库 *.pb.go/connect)→ 删 all.go 两行 → 删 impl 目录 → 重建。
- 菜单自动清理:
syncMenus 启动时自动 prune 孤儿插件菜单——DB 里 plg_ 前缀但已不属任何已注册插件的菜单(及其 RoleMenu/MenuButton/RoleButton)会被删除。故移除插件 + 重建后,其后台菜单(含分组头)自动消失,无需手动清。数据表/apis 不在此自动删(避免误删业务数据)。
- 卸载残留(v1 无自动 teardown):syncMenus/syncAPIs 均 insert-only、gormigrate 无 down,卸载后遗留残留;菜单已由启动自动 prune(见上),
<name>_teardown.sql 覆盖其余(按序):(1) plg_<name>_* 数据表;(2)〔菜单已自动清理,teardown 仍含相应 DELETE 作离线清库用〕;(3) apis 行;(4) 管理员手配的 casbin_rule;(5) scheduled_jobs + job_executions(handler 前缀 <name>_);(6) migrations 账本行 plg_<name>_*——不删则同名重装会跳过建表导致缺表;(7) plugin_states 行——不删则同名重装可能以禁用态启动。LIKE 下划线均 ESCAPE '',防前缀重叠(shop vs shopping)。最小化保留数据则改置菜单 hidden=true、插件 disable。
可靠性
插件 job panic 已被 internal/jobs/scheduler.go wrap() 的 defer recover() 隔离:单 job panic 记 JobExecution status=error,不拖垮调度器/进程。
循环引用约束(改签名时务必守)
两道防线根除 database→plugin→jobs 环:(1) Plugin 接口不出现 jobs.* 类型(用 plugin.JobHandler);(2) database 包不 import plugin——Migrate(db, extra) / Seed(db, extraMenus []MenuSeed) 取普通 slice,由 cmd/server/main.go(可同时 import 两者)组装入参。job 收集落 main.go(调度器 registry)+ server.go(UI registry)。破坏任一边会让 go test ./internal/jobs 编译失败。
源码锚点
internal/plugin/{plugin,registry,validate}.go、internal/plugins/all.go、internal/plugin/impl/shop/(示例)、cmd/gettopic/(脚手架 + 插件生成器:gettopic plugin new)、web/src/routes/_authed/p.$.tsx(后台 splat)、web/src/routes/pub.$.tsx(公开 splat)、web/src/plugin-components/、internal/database/{migrate,seed}.go、internal/server/server.go、internal/jobs/scheduler.go。设计全文:.plans/plugin-system-plan.md。
1---2name: zerx-plugin3description: gettopic 编译期插件机制:以低侵入方式新增后台管理模块(后端 RPC + 表 + 前端动态页 + 菜单 + 授权),不破坏单二进制 / CGO-free / Casbin 唯一授权 / proto 唯一契约。当用户要用插件方式扩展、用 task new-plugin 脚手架、新增/启用/禁用/卸载插件、或排查插件注册/校验/动态前端页问题时使用。Keywords: plugin, 插件, plugin system, 插件机制, task new-plugin, task plugin-pack, 脚手架, scaffold, 上传插件, 安装插件, 卸载插件, install plugin, uninstall plugin, upload, zip, InstallPlugin, UninstallPlugin, internal/plugin, installer, all.go, ValidateAll, RegisterHandlers, 动态路由, import.meta.glob, p.$, pub.$, plugin-components, 编译期插件, compile-time plugin, 插件注册, 插件禁用, 插件卸载, teardown, PLUGIN_UPLOAD_ENABLED4---5# gettopic 编译期插件机制67> 编译期静态插件(源码内置 + 重新构建):自带 RPC/表/迁移/菜单/授权声明/可选 job;显式 `Register()` 注册;启动期 `ValidateAll` 强校验;前端单一动态路由按 `menu.component` 加载页。**有意不用** WASM/.so/go-plugin/init() 魔法(理由见设计裁决)。89## 加一个插件(脚手架,推荐)10```11task new-plugin -- <name> [field:type,...] # 例: task new-plugin -- blog title:string,views:int(shop 已作为内置示例)12task gen # 生成 proto Go/TS + connect-query hook13# 填 internal/plugin/impl/<name>/service.go 业务逻辑(脚手架已给 CRUD stub)14task build && 重启15```16- `name` 须匹配 `^[a-z][a-z0-9_]{0,30}$`。字段类型:`string|int|int32|bool|float`;`id`/`name` 为内置字段,传入会被忽略(避免重复)。17- 生成器产出:`proto/zerx/v1/<name>.proto`、`internal/plugin/impl/<name>/{plugin,model,service}.go`、`<name>_teardown.sql`、`web/src/plugin-components/<name>/<Pascal>.tsx`,并在 `internal/plugins/all.go` 两处锚点插行(`// plugin-import-anchor` / `// plugin-register-anchor`)后 gofmt。1819## 手动新增插件的核心改动 = 仅 all.go 两行20装配点(server.New / main.go)各只接一次钩子,之后永不再改。新增插件**唯一**核心改动是 `internal/plugins/all.go`:import 块加一行 + `Register()` 体加一行。2122## Plugin 契约(`internal/plugin/plugin.go`)23实现 `plugin.Plugin` 接口(示例见 `internal/plugin/impl/shop/plugin.go`):24- `Name()` 小写蛇形,全局唯一。25- `Services()` 本插件 connectRPC service 全名(如 `zerx.v1.ShopProductService`)——`ValidateAll` 据此校验 public/selfServe procedure 归属。26- `Migrations()` gormigrate 迁移,ID 须 `plg_<name>_` 前缀;`TableNames()` 表名须 `plg_<name>_` 前缀。27- `SeedMenus()` 菜单 `Name` 须 `plg_<name>` 前缀、`Path` 须 `/p/<name>` 前缀;`Component` 为 glob 相对标识(如 `shop/Shop`);`UserVisible` 控制是否默认授 user 角色 RoleMenu。28- `PublicProcedures()/SelfServeProcedures()` 必属本插件 service(否则启动 fatal,堵提权)。29- `RegisterHandlers(reg, deps)` 用 `deps.Opts`(完整拦截器链)注册 handler;`deps` 含 `DB/Opts/Enforcer/Media/Logger`。30- `JobHandlers()` 可选,key 须 `<name>_` 前缀;类型是 `plugin.JobHandler{Run,Description}`(**不引 jobs.Descriptor**,断开循环引用)。3132## 启动期校验(`ValidateAll`,失败 fatal)33main.go 在 `plugins.Register()` 后、任何 DB 操作前调用,校验:名字正则+唯一、迁移ID前缀、表名前缀、job key 前缀、**Service 名以插件 PascalCase 前缀且全局唯一**、**public/selfServe procedure 必属本插件 service**、菜单 Name/Path 命名空间且与核心 seed 及其他插件无冲突、public page 路径 `/pub/<name>` 命名空间。34- **提权防线(安全核)**:`Services()` 每项 short name 必须以插件 PascalCase 名开头(`shop` → `ShopProductService`),且一个 service 全局只能属一个插件。这闸死了"插件声明拥有核心 service(如 `zerx.v1.UserService`)再把其 procedure 洗进 public/selfServe map"的提权通道——否则 procedure-ownership 校验会被架空。35- **已知边界(信任模型,见设计 Assumption A)**:`TableNames()` 是插件**自声明**列表,ValidateAll 校验的是该声明的前缀,**不内省迁移实际建的表**。插件被定位为同仓库可重建二进制的可信协作方;若迁移里 `AutoMigrate` 一张非前缀(甚至核心)表,ValidateAll 不拦——靠 code review / CI 把关。务必让 model 的 `TableName()` 返回 `plg_<name>_*` 且与 `TableNames()` 一致(示例 shop 已如此)。3637## 授权(Casbin 唯一权威,零拦截器改动)38插件 handler 经同一 `opts` 注册 → 自动受 Casbin 保护。插件 procedure 默认仅 admin 可用(admin bypass)。让非 admin 角色用上插件页需管理员在 UI 配两类:RoleMenu(菜单可见)+ RolePermission/casbin_rule(接口放行)。**插件不自行 seed casbin_rule 提权**。细节 → `skill://zerx-authz`。3940## 前端动态页(承重机制)41- 页放 `web/src/plugin-components/<name>/<Component>.tsx`(**default export**)。42- 单一 splat 路由 `web/src/routes/_authed/p.$.tsx`(参数 `_splat`):`import.meta.glob('/src/plugin-components/**/*.tsx')` 编译期收集;用 `path==='/p/'+_splat` 在用户菜单树找到 `menu.component`,拼 key `/src/plugin-components/${component}.tsx`,`React.lazy` 加载;找不到渲染 NotFound。splat 同时支持扁平 `/p/<name>` 与分组子页 `/p/<name>/<sub>`。43- **分组菜单**(一级带多个二级):`SeedMenus` 返回一个 group node(`Path: ""`)+ `Children` 多个叶子(各自 `Path: "/p/<name>/<sub>"`、`Component: "<name>/<Sub>"`);参考 shop 示例(商城分组 → 商品/分类)。validate 允许叶子 path 为 `/p/<name>` 或 `/p/<name>/...`。44- 侧边栏(`route.tsx`):`/p/` 前缀菜单走 `<Link to="/p/$" params={{_splat: menu.path.slice(3)}}>`,非插件菜单保持 `<Link to={menu.path}>`。45- i18n(**自包含,glob 自动收集**):菜单 Title / 页面文案用 key `plg.<name>.<k>`,翻译放插件目录内 `web/src/plugin-components/<name>/i18n.ts`(**default export** `{ en:{...}, zh:{...} }`,en/zh key 同步)。`web/src/lib/i18n.tsx` 用 `import.meta.glob('/src/plugin-components/**/i18n.ts', { eager:true })` 编译期收集,按目录名合并进 `plg.<name>` 命名空间——**与组件 glob 同构**。故 ZIP 安装的插件翻译随 `web/` 解压落回原位、重建即生效,**无需改 `i18n.tsx`、无需改 installer**(手动新增插件同理,生成器已产出 `i18n.ts` 模板)。图标在 `web/src/lib/menu-icons.ts` 注册(未注册回退 CircleIcon)。4647## 插件管理页(运行时启用/禁用)48- 后台「插件管理」页 `web/src/routes/_authed/plugins.tsx`(菜单 `/plugins`,Casbin 仅 admin)经 `PluginService`(`proto/zerx/v1/plugin.proto`)透出已编译插件清单 + 运行时启停。49- **编译期约束**:install/uninstall 需重建二进制,UI 不做(仅文档化指引)。enable/disable 是**运行时开关**,无需重建。50- **运行时状态** `internal/plugin/state.go`(`plugin.State`,核心表 `plugin_states`,默认 enabled;`SetEnabled` 用 map upsert 以持久化 `enabled=false`——struct 写会被 `default:true`/零值跳过吞掉)。禁用插件三处 gate:(a) `casbin_interceptor` 在所有放行路径(含 admin)之前 `procEnabled` 拒绝 → `CodeUnavailable`;(b) `MenuService.GetUserMenus` 过滤 `PluginNameOfMenu` 命中的被禁菜单;(c) `scheduler.wrap` 在 fire time 经 `SetHandlerEnabled` 跳过被禁插件 job。三者由 `main.go` 注入同一 `plugin.State`(多副本驱动 postgres/mysql 经 `StartReloader` 周期重载,使一副本的启停传播到其他副本)。51- **数据保留**:禁用**只**关闭菜单/接口/job,**不动表、数据、迁移**;启用即恢复对同一批数据的访问(`SetEnabled` 仅写 `plugin_states.enabled`)。52- **菜单 reconcile**:`syncMenus` 对插件菜单(`plg_` 前缀)**同步** path/component/parent/title/icon/hidden(结构仍以 SeedMenus 为准);**不覆盖 `sort`**(admin 拖拽 / `UpdateMenu` 跨重启保留)。新插入的顶层插件菜单 `Sort = dashboard.Sort+1`,排在仪表盘下、系统/审计组之上(核心 `system=10`/`audit=20` 留空档)。核心菜单仍 insert-only。改插件菜单结构(如扁平页变分组子页)重启即生效,无需手动清库。5354## 上传安装 / 卸载(GVA 式,脚手架机制)5556> **零插件不变量(关键)**:`internal/plugins/all.go` 含 `var _ = plugin.All` sentinel —— 即使卸载最后一个插件、`Register()` 体清空,`plugin` 包 import 也不会变成 "imported and not used"。**卸载任何插件(含最后一个)都不会让程序 build/run 失败。** installer 写 all.go 前用 `go/parser` 校验仍是合法 Go,拒绝写入坏文件。删 sentinel 会破坏此不变量。57编译期插件无法免重建热插拔(Go 限制)。「上传 zip 安装」= 把源码解压进仓库 + 改 all.go,**装后重启生效**(dev 由 air 自动重编重启;生产手动 `task gen && task build` 重新部署)。58- **打包**:`task plugin-pack -- <name>` → `<name>.zip`(`plugin.json` + `proto/<name>.proto` + `impl/` + `web/`)。59- **安装**:插件管理页「上传插件」→ `PluginService.InstallPlugin(bytes package)` → `internal/plugin/installer` 校验(manifest name 正则+非 Go 关键字、zip-slip 防护、≤256 文件 / ≤25MB、不覆盖已存在)→ 写盘 + all.go 插行(锚点在**独立注释行**,故卸载锚点插件也安全)→ dev 自动 `task gen`。60- **卸载后的中间态(重要)**:卸载只删磁盘源码+all.go 行,但**当前运行的二进制仍编进了该插件**,故 `ListPlugins`(返回 `plugin.All()`)重启前仍列出它。后端检测 impl 目录已不在磁盘 → 置 `PluginInfo.pending_removal=true`,前端该行显「待重启移除」徽章并禁用启停/卸载按钮。dev 下 air 重编重启后 shop 从 registry 消失、该行自动不再出现;生产需 `task build` 重部。61- **卸载**:每行垃圾桶按钮 → `UninstallPlugin(name, purge_data)`。删源码 + all.go 行。`purge_data=true`(确认框点「确定」)走 `teardownData`:**DROP 插件表** + 硬删(`Unscoped()`)menus/grants/jobs/apis/casbin_rule/migrations/plugin_states——真正清空,同名重装从零开始;`purge_data=false` 保留数据。62- **安全门控**:两接口 Casbin 仅 admin **且** `config.UploadAllowed()`(`PLUGIN_UPLOAD_ENABLED` 显式优先,否则仅非 prod)——上传源码 = RCE 级,生产默认关闭。`ListPluginsResponse.upload_allowed` 透给前端,关闭时隐藏上传/卸载按钮。请求体经 `WithReadMaxBytes(25MB)` 限流(connect 在 gate 前缓冲)。63- 注:已编译进进程的插件清单(`ListPlugins`)在重启前不反映新装/已卸载插件——`pending_restart` 提示用户重启。6465## 纯前台 / 公开页(匿名,官网类)66插件可服务**无需登录**的前台页(如官网、落地页),与后台菜单/API 并存:67- Plugin 接口 `PublicPages() []PublicPage{Path,Component,Title}` 声明,Path 必须 `/pub/<name>` 或 `/pub/<name>/...`(validate 校验)。68- 匿名接口 `PluginService.ListPublicPages`(在 server.go 的 `public` map,免认证)返回**已启用**插件的公开页;前端公开 splat 路由 `web/src/routes/pub.$.tsx`(**顶层,不在 `_authed` 下,无 auth**)据此按 `/pub/...` 匹配 → glob 加载组件。69- 禁用插件 → `ListPublicPages` 不再返回其页 → `/pub/<name>` 渲染 NotFound(公开页随插件启停)。70- 组合能力:一个插件可同时有 后台菜单(`SeedMenus`)+ 鉴权 API(默认 Casbin)+ 匿名 API(`PublicProcedures`)+ 匿名前台页(`PublicPages`)。示例见 shop 的 `/pub/shop` Landing。7172## 生命周期:启用 / 禁用 / 卸载73- **启用**:见上「加一个插件」。迁移/菜单/job 自动接入。74- **禁用(关键约束)**:`assertServicesRegistered` 对**已编译进 proto 描述符**的 zerx.v1 service 强制要求挂载。**仅删 all.go 的 Register 行而保留插件 proto/生成代码 → 启动 fatal**。正确禁用 = 删 `proto/zerx/v1/<name>.proto` → `task gen`(清除已入库 *.pb.go/connect)→ 删 all.go 两行 → 删 impl 目录 → 重建。75- **菜单自动清理**:`syncMenus` 启动时**自动 prune** 孤儿插件菜单——DB 里 `plg_` 前缀但已不属任何已注册插件的菜单(及其 RoleMenu/MenuButton/RoleButton)会被删除。故移除插件 + 重建后,其后台菜单(含分组头)**自动消失**,无需手动清。数据表/apis 不在此自动删(避免误删业务数据)。76- **卸载残留(v1 无自动 teardown)**:syncMenus/syncAPIs 均 insert-only、gormigrate 无 down,卸载后遗留残留;菜单已由启动自动 prune(见上),`<name>_teardown.sql` 覆盖其余(按序):(1) `plg_<name>_*` 数据表;(2)〔菜单已自动清理,teardown 仍含相应 DELETE 作离线清库用〕;(3) `apis` 行;(4) 管理员手配的 `casbin_rule`;(5) `scheduled_jobs` + `job_executions`(handler 前缀 `<name>_`);(6) **`migrations` 账本行 `plg_<name>_*`**——不删则同名重装会跳过建表导致缺表;(7) **`plugin_states` 行**——不删则同名重装可能以禁用态启动。LIKE 下划线均 `ESCAPE ''`,防前缀重叠(shop vs shopping)。最小化保留数据则改置菜单 `hidden=true`、插件 disable。7778## 可靠性79插件 job panic 已被 `internal/jobs/scheduler.go` `wrap()` 的 `defer recover()` 隔离:单 job panic 记 JobExecution status=error,不拖垮调度器/进程。8081## 循环引用约束(改签名时务必守)82两道防线根除 `database→plugin→jobs` 环:(1) Plugin 接口不出现 `jobs.*` 类型(用 `plugin.JobHandler`);(2) `database` 包不 import `plugin`——`Migrate(db, extra)` / `Seed(db, extraMenus []MenuSeed)` 取普通 slice,由 `cmd/server/main.go`(可同时 import 两者)组装入参。job 收集落 main.go(调度器 registry)+ server.go(UI registry)。破坏任一边会让 `go test ./internal/jobs` 编译失败。8384## 源码锚点85`internal/plugin/{plugin,registry,validate}.go`、`internal/plugins/all.go`、`internal/plugin/impl/shop/`(示例)、`cmd/gettopic/`(脚手架 + 插件生成器:gettopic plugin new)、`web/src/routes/_authed/p.$.tsx`(后台 splat)、`web/src/routes/pub.$.tsx`(公开 splat)、`web/src/plugin-components/`、`internal/database/{migrate,seed}.go`、`internal/server/server.go`、`internal/jobs/scheduler.go`。设计全文:`.plans/plugin-system-plan.md`。