Add system-managed setting scope contract to SettingDesc
Why: P2 受管设置机制需要 schema 声明写入归属——system 受管项仅管理员可写,user/project 层拒绝写入且生效链忽略存量覆盖。 Evidence: go test ./... 全绿(ManagedPaths/Touches/Strip 含祖先标量覆盖与整棵子树剥离用例)。 Impact: 新增 SettingScope 常量、SettingDesc.Scope 字段与三个纯函数;空 Scope 保持既有行为,向后兼容。 Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2
This commit is contained in:
+125
-1
@@ -1,6 +1,9 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// 智能体画像(4.7):核心定制机制,全部数据化、可导入导出。
|
// 智能体画像(4.7):核心定制机制,全部数据化、可导入导出。
|
||||||
type AgentProfile struct {
|
type AgentProfile struct {
|
||||||
@@ -107,6 +110,16 @@ const (
|
|||||||
EffectRestart SettingEffect = "restart" // 需重启
|
EffectRestart SettingEffect = "restart" // 需重启
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SettingScope 声明设置项的写入归属(提案 2026-10-13-rdm-settings-members-duties-perms-increment P2)。
|
||||||
|
// 空串 = 默认:user 与 project 层可写(向后兼容既有 schema)。
|
||||||
|
type SettingScope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SettingScopeSystem 受管项:仅系统管理面(admin)可写;user/project 写入被拒,
|
||||||
|
// 生效链忽略这两层的存量覆盖值。
|
||||||
|
SettingScopeSystem SettingScope = "system"
|
||||||
|
)
|
||||||
|
|
||||||
// SettingDesc 描述一个设置项;设置页按此渲染并标注生效时机(schema 驱动)。
|
// SettingDesc 描述一个设置项;设置页按此渲染并标注生效时机(schema 驱动)。
|
||||||
type SettingDesc struct {
|
type SettingDesc struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@@ -118,6 +131,117 @@ type SettingDesc struct {
|
|||||||
Options []string `json:"options,omitempty"` // enum
|
Options []string `json:"options,omitempty"` // enum
|
||||||
Effect SettingEffect `json:"effect"`
|
Effect SettingEffect `json:"effect"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Scope SettingScope `json:"scope,omitempty"` // 空 = user/project 可写;system = 受管
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedPaths 返回 schema 中 scope=system 的键路径(点路径形式)。
|
||||||
|
func ManagedPaths(descs []SettingDesc) []string {
|
||||||
|
out := make([]string, 0, len(descs))
|
||||||
|
for _, d := range descs {
|
||||||
|
if d.Scope == SettingScopeSystem {
|
||||||
|
out = append(out, d.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlattenSettingLeafPaths 列出 values 的全部叶子点路径。子树为标量或数组时该子树
|
||||||
|
// 根即叶子。用于受管触碰检测:深合并语义下叶子决定最终覆盖面。
|
||||||
|
func FlattenSettingLeafPaths(values map[string]any) []string {
|
||||||
|
var out []string
|
||||||
|
var walk func(prefix string, m map[string]any)
|
||||||
|
walk = func(prefix string, m map[string]any) {
|
||||||
|
for k, v := range m {
|
||||||
|
path := k
|
||||||
|
if prefix != "" {
|
||||||
|
path = prefix + "." + k
|
||||||
|
}
|
||||||
|
if vm, ok := v.(map[string]any); ok && len(vm) > 0 {
|
||||||
|
walk(path, vm)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk("", values)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathTouchesManaged 判断 path 是否触碰 managed:等于受管路径、位于受管子树内
|
||||||
|
// (受管路径是 path 的前缀),或整体覆盖受管子树(path 是受管路径的前缀——
|
||||||
|
// 标量覆盖整棵受管 map 同样破坏受管值)。
|
||||||
|
func pathTouchesManaged(path string, managed []string) bool {
|
||||||
|
for _, mp := range managed {
|
||||||
|
if path == mp ||
|
||||||
|
strings.HasPrefix(path, mp+".") ||
|
||||||
|
strings.HasPrefix(mp, path+".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchesManagedSettings 返回 values 中触碰受管路径的叶子;空切片 = 无触碰。
|
||||||
|
// 调用方须传入已展开点路径的嵌套 map(normalize 后)。
|
||||||
|
func TouchesManagedSettings(values map[string]any, managed []string) []string {
|
||||||
|
var hits []string
|
||||||
|
for _, p := range FlattenSettingLeafPaths(values) {
|
||||||
|
if pathTouchesManaged(p, managed) {
|
||||||
|
hits = append(hits, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripManagedSettings 返回删除受管路径后的浅拷贝(不改入参)。受管路径的祖先
|
||||||
|
// 若因此只剩空 map 一并删除。入参须为嵌套形式(点路径已展开)。
|
||||||
|
func StripManagedSettings(values map[string]any, managed []string) map[string]any {
|
||||||
|
if len(values) == 0 || len(managed) == 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
// 叶子命中或祖先整体命中都删;逐叶子重建最简单可靠。
|
||||||
|
leaves := FlattenSettingLeafPaths(values)
|
||||||
|
keep := map[string]any{}
|
||||||
|
for _, leaf := range leaves {
|
||||||
|
if pathTouchesManaged(leaf, managed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
setPath(keep, leaf, lookupAny(values, leaf))
|
||||||
|
}
|
||||||
|
return keep
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPath(m map[string]any, path string, val any) {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
cur := m
|
||||||
|
for i, p := range parts {
|
||||||
|
if i == len(parts)-1 {
|
||||||
|
cur[p] = val
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next, ok := cur[p].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
next = map[string]any{}
|
||||||
|
cur[p] = next
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupAny(m map[string]any, path string) any {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
var cur any = m
|
||||||
|
for _, p := range parts {
|
||||||
|
cm, ok := cur.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cur, ok = cm[p]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveSettings 是覆盖链(system → user → project → conversation)深合并结果。
|
// EffectiveSettings 是覆盖链(system → user → project → conversation)深合并结果。
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagedPathsFiltersSystemScope(t *testing.T) {
|
||||||
|
descs := []SettingDesc{
|
||||||
|
{Key: "models.chat.model", Scope: SettingScopeSystem},
|
||||||
|
{Key: "ui.theme"},
|
||||||
|
{Key: "tools.tavily.apiKeys", Scope: SettingScopeSystem},
|
||||||
|
}
|
||||||
|
got := ManagedPaths(descs)
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"models.chat.model", "tools.tavily.apiKeys"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("ManagedPaths = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchesManagedSettings(t *testing.T) {
|
||||||
|
managed := []string{"models.chat.model", "tools.tavily.apiKeys", "telemetry.otlpEndpoint"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in map[string]any
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"exact leaf", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "x"}}}, []string{"models.chat.model"}},
|
||||||
|
{"sibling untouched", map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": "x"}}}, nil},
|
||||||
|
{"ancestor scalar overwrite", map[string]any{"models": "oops"}, []string{"models"}},
|
||||||
|
{"subtree inside managed", map[string]any{"telemetry": map[string]any{"otlpEndpoint": "http://x"}}, []string{"telemetry.otlpEndpoint"}},
|
||||||
|
{"other group untouched", map[string]any{"ui": map[string]any{"theme": "light"}}, nil},
|
||||||
|
{"empty map", map[string]any{}, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := TouchesManagedSettings(tc.in, managed)
|
||||||
|
sort.Strings(got)
|
||||||
|
if tc.want == nil && len(got) != 0 {
|
||||||
|
t.Fatalf("TouchesManagedSettings = %v, want none", got)
|
||||||
|
}
|
||||||
|
if tc.want != nil && !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("TouchesManagedSettings = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripManagedSettings(t *testing.T) {
|
||||||
|
managed := []string{"models.chat.model", "models.chat.apiKey"}
|
||||||
|
in := map[string]any{
|
||||||
|
"models": map[string]any{
|
||||||
|
"chat": map[string]any{"model": "x", "baseUrl": "https://b"},
|
||||||
|
"lightweight": map[string]any{"model": "y"},
|
||||||
|
},
|
||||||
|
"ui": map[string]any{"theme": "dark"},
|
||||||
|
}
|
||||||
|
got := StripManagedSettings(in, managed)
|
||||||
|
want := map[string]any{
|
||||||
|
"models": map[string]any{
|
||||||
|
"chat": map[string]any{"baseUrl": "https://b"},
|
||||||
|
"lightweight": map[string]any{"model": "y"},
|
||||||
|
},
|
||||||
|
"ui": map[string]any{"theme": "dark"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("StripManagedSettings = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if in["models"].(map[string]any)["chat"].(map[string]any)["model"] != "x" {
|
||||||
|
t.Fatal("StripManagedSettings 不应修改入参")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripManagedSettingsWholeSubtree(t *testing.T) {
|
||||||
|
managed := []string{"models.chat.model", "models.chat.baseUrl", "models.chat.apiKey", "models.chat.fallbacks"}
|
||||||
|
in := map[string]any{
|
||||||
|
"models": map[string]any{"chat": map[string]any{"model": "x", "baseUrl": "b", "apiKey": "k"}},
|
||||||
|
}
|
||||||
|
got := StripManagedSettings(in, managed)
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("整棵受管子树应被剥除(含空父),got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user