feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
AskaEth committed 2026-10-06 08:06:20 +08:00
1 parent 6e28b38b08
commit c9144c1096
19 files changed
+3053

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
package paneldecl
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
// 本文件是目录的**拷贝语义**:存进来时拷一次、发出去时再拷一次。
//
// 为什么要两次:§5.2.1 的不变量是「`List` 返回的必须是拷贝」——调用方(gateway / 插件管理器)
// 拿着结果做过滤或补字段时,绝不能改到目录内部状态;反向同理,插件管理器注册后继续改自己
// 那份声明,也不能影响目录里的快照。声明是嵌套结构(sections → fields → 指针字段),
// 浅拷贝会把内部切片暴露出去,所以这里逐层深拷。
// clonePanel 深拷一份面板声明。
func clonePanel(p sdk.Panel) sdk.Panel {
out := p
out.When = cloneWhen(p.When)
if p.Sections != nil {
out.Sections = make([]sdk.Section, len(p.Sections))
for i, s := range p.Sections {
out.Sections[i] = cloneSection(s)
}
}
return out
}
// cloneSection 深拷一个区块(切片与指针字段都要换新)。
func cloneSection(s sdk.Section) sdk.Section {
out := s
if s.Fields != nil {
out.Fields = make([]sdk.Field, len(s.Fields))
for i, f := range s.Fields {
out.Fields[i] = cloneField(f)
}
}
if s.Columns != nil {
out.Columns = make([]sdk.Column, len(s.Columns))
copy(out.Columns, s.Columns)
}
if s.RowActions != nil {
out.RowActions = make([]sdk.RowAction, len(s.RowActions))
copy(out.RowActions, s.RowActions)
}
if s.Submit != nil {
submit := *s.Submit
out.Submit = &submit
}
if s.Source != nil {
source := *s.Source
out.Source = &source
}
return out
}
// cloneField 深拷一个字段(Default 是 any,可能装着 map/slice)。
func cloneField(f sdk.Field) sdk.Field {
out := f
out.Default = cloneValue(f.Default)
if f.Min != nil {
min := *f.Min
out.Min = &min
}
if f.Max != nil {
max := *f.Max
out.Max = &max
}
return out
}
// cloneWhen 深拷 when(Enabled 是指针,共享指针等于共享可变状态)。
func cloneWhen(w *sdk.When) *sdk.When {
if w == nil {
return nil
}
out := &sdk.When{}
if w.Enabled != nil {
enabled := *w.Enabled
out.Enabled = &enabled
}
return out
}
// cloneValue 递归拷贝默认值里的容器。标量(字符串/数字/布尔)本身不可变,直接返回。
//
// 未覆盖的类型(声明由 Go 代码直接构造时可能塞进任意类型)按原值返回并在文档里写明:
// 本包只承诺「结构声明不可被调用方改坏」,不承诺替调用方深拷它自己塞进来的自定义类型。
func cloneValue(v any) any {
switch val := v.(type) {
case map[string]any:
out := make(map[string]any, len(val))
for k, item := range val {
out[k] = cloneValue(item)
}
return out
case map[string]string:
out := make(map[string]string, len(val))
for k, item := range val {
out[k] = item
}
return out
case []any:
out := make([]any, len(val))
for i, item := range val {
out[i] = cloneValue(item)
}
return out
case []string:
out := make([]string, len(val))
copy(out, val)
return out
default:
return v
}
}