Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
485 lines
29 KiB
Go
485 lines
29 KiB
Go
// Package settings 实现分层设置(4.9):system 默认 → user → project → conversation,
|
||
// schema 驱动设置页(生效时机 hot / new-run / restart 显式标注)。
|
||
package settings
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// Scope 层级键:user 与 project:<id> 落库;system 是管理员全局层(owner 固定
|
||
// OwnerSystemRow);conversation 经会话画像选择实现。
|
||
const (
|
||
ScopeUser = "user"
|
||
ScopeProjectPrefix = "project:"
|
||
ScopeSystem = "system"
|
||
// OwnerSystemRow 系统层落库行的 owner。空串不是任何账号(账号名非空),
|
||
// 租户 API 无法读写该行。
|
||
OwnerSystemRow = ""
|
||
)
|
||
|
||
// ErrManagedPath 表示写入负载触碰系统受管设置项;user/project 层拒绝整笔写入。
|
||
type ErrManagedPath struct{ Paths []string }
|
||
|
||
func (e *ErrManagedPath) Error() string {
|
||
return "系统受管设置项仅管理员可配置: " + strings.Join(e.Paths, ", ")
|
||
}
|
||
|
||
// SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。
|
||
func SystemDefaults() map[string]any {
|
||
return map[string]any{
|
||
"models": map[string]any{
|
||
"chat": map[string]any{
|
||
"baseUrl": "", // 例:https://api.openai.com/v1 (OpenAI 兼容)
|
||
"apiKey": "",
|
||
"model": "",
|
||
"fallbacks": "", // 逗号分隔;空 = 不回退
|
||
},
|
||
"lightweight": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
|
||
"embedding": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
|
||
"rerank": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
|
||
},
|
||
"tools": map[string]any{
|
||
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
|
||
},
|
||
"runtime": map[string]any{
|
||
"mock": false, // true = 全部会话走 mock runner(演示/测试)
|
||
"squad": true, // 小队编排(Agent-Squad 引擎)可用
|
||
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
|
||
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
|
||
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
|
||
"verify": true, // 终轮确定性检查与独立验证
|
||
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
|
||
"codeOrchestration": false, // 模型用脚本批量调用工具;默认关
|
||
"materialIntake": true, // 附件即保证:带附件回合系统确定性启动材料整理后台任务
|
||
"materialIntakeChars": 12000, // 带文字发送时材料总量达到该字符数也预启动(否则回合结束兜底)
|
||
},
|
||
"storage": map[string]any{
|
||
"dataDir": "./data", // 启动注入实际值(--data)
|
||
},
|
||
"context": map[string]any{
|
||
"maxHistoryMessages": 40,
|
||
"maxToolResultChars": 20000,
|
||
"userInstructions": "",
|
||
"contextWindow": 32000,
|
||
"messageShape": "merge",
|
||
},
|
||
"crossSession": map[string]any{
|
||
"read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读
|
||
},
|
||
"retrieval": map[string]any{"topK": 5},
|
||
"ui": map[string]any{"theme": "dark", "cardflow": true}, // 长产物进卡片默认开(用户 2026-10-11 拍板)
|
||
"deliverables": map[string]any{
|
||
"style": "modern", // modern / formal / minimal
|
||
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
|
||
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
|
||
},
|
||
"plugins": map[string]any{
|
||
"dir": "./plugins",
|
||
"generative-ui": map[string]any{
|
||
"autoPresent": false, // 自动建议页面:默认关;需同时开启 Surface activation
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
|
||
func Schema() []core.SettingDesc {
|
||
return []core.SettingDesc{
|
||
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
||
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
|
||
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
|
||
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
|
||
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "清理阈值里为模型输出预留的 token 数"},
|
||
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "避免贴着上限才清理的余量"},
|
||
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "标题生成/澄清等轻任务;空则回落主模型"},
|
||
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
|
||
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
||
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "不调用真实模型,按脚本回放事件流"},
|
||
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
|
||
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
|
||
{Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"},
|
||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "0 = 不限;多用户建议 300–1200"},
|
||
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
|
||
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
|
||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
|
||
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
|
||
{Key: "runtime.dualSpeedNudgeSeconds", Group: "runtime", Label: "快慢双速提醒阈值(秒)", Type: core.SettingNumber, Default: 75, Effect: core.EffectNewRun, Description: "任务连续运行超过该秒数、用户还没收到任何答复、也没把深度工作放后台时,一次性提醒模型转快慢双速:先把判断/概要/计划回复给用户,重活用 delegate_task 的 background 放后台(SR-B10)。0 = 关闭"},
|
||
{Key: "runtime.materialIntake", Group: "runtime", Label: "附件即材料整理", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "带附件的发送由系统确定性启动材料整理后台任务(无文字或大材料立即启动;文字+小材料回合结束兜底),进度卡即时入收件箱。关闭 = 回到模型自行判断"},
|
||
{Key: "runtime.materialIntakeChars", Group: "runtime", Label: "材料预启动阈值(字符)", Type: core.SettingNumber, Default: 12000, Effect: core.EffectNewRun, Description: "带文字发送时,附件解析文本总量达到该字符数也立即预启动材料整理;未达到则模型先答、回合结束兜底"},
|
||
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
|
||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"},
|
||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
||
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
|
||
{Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 <think> 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"},
|
||
{Key: "context.messageShape", Group: "context", Label: "消息整形", Type: core.SettingEnum, Default: "merge", Options: []string{"merge", "strict"}, Effect: core.EffectNewRun, Description: "merge 合并相邻同角色。strict 再把多余的 system 收成 user,满足严格交替"},
|
||
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
|
||
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
|
||
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
|
||
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"},
|
||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
|
||
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
|
||
}
|
||
}
|
||
|
||
// Service 提供有效设置计算与分层写入。
|
||
type Service struct {
|
||
st *store.Store
|
||
system map[string]any // 启动注入的系统层覆盖(如数据目录),位于 defaults 与 user 之间
|
||
// extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入);
|
||
// 受管路径计算同时覆盖内置 schema 与插件声明。
|
||
extraSchema []core.SettingDesc
|
||
}
|
||
|
||
func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||
|
||
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
|
||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||
|
||
// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。
|
||
func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = descs }
|
||
|
||
// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。
|
||
func (s *Service) ManagedSettingPaths() []string {
|
||
paths := core.ManagedPaths(Schema())
|
||
if s != nil && len(s.extraSchema) > 0 {
|
||
paths = append(paths, core.ManagedPaths(s.extraSchema)...)
|
||
}
|
||
return paths
|
||
}
|
||
|
||
// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库)
|
||
// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。
|
||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||
eff := core.MergeSettings(SystemDefaults(), map[string]any{})
|
||
if s.system != nil {
|
||
eff = core.MergeSettings(eff, s.system)
|
||
}
|
||
if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil {
|
||
eff = core.MergeSettings(eff, sysdb)
|
||
}
|
||
managed := s.ManagedSettingPaths()
|
||
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
||
eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(user), managed))
|
||
}
|
||
if projectID != "" {
|
||
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
||
eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(proj), managed))
|
||
}
|
||
}
|
||
return eff
|
||
}
|
||
|
||
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
||
return s.readScopeStripped(owner, ScopeUser)
|
||
}
|
||
|
||
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
||
return s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
|
||
}
|
||
|
||
// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页
|
||
// 回显为"当前值"(存量值保留在库中作为迁移依据,P8 处置)。
|
||
func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error) {
|
||
m, err := s.st.GetSettingsScope(owner, scope)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return core.StripManagedSettings(normalizeKeys(m), s.ManagedSettingPaths()), nil
|
||
}
|
||
|
||
// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。
|
||
func (s *Service) SystemScope() (map[string]any, error) {
|
||
return s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem)
|
||
}
|
||
|
||
func (s *Service) MergeUser(owner string, values map[string]any) error {
|
||
return s.mergeScope(owner, ScopeUser, values)
|
||
}
|
||
|
||
func (s *Service) MergeProject(owner, projectID string, values map[string]any) error {
|
||
return s.mergeScope(owner, ScopeProjectPrefix+projectID, values)
|
||
}
|
||
|
||
// MergeSystem 写管理员系统层(仅系统管理 API 调用;不做受管拦截——系统层
|
||
// 就是受管项的权威写入面;非受管项写入形成全局默认覆盖,用户层仍可再覆盖)。
|
||
func (s *Service) MergeSystem(values map[string]any) error {
|
||
return s.mergeScope(OwnerSystemRow, ScopeSystem, values)
|
||
}
|
||
|
||
// MergeUserAccountChecked and MergeProjectAccountChecked are request-facing
|
||
// writes. Their final Store transaction locks and checks the actor before the
|
||
// scope lock and write; startup migration keeps using the legacy methods.
|
||
func (s *Service) MergeUserAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error {
|
||
return s.mergeScopeAccountChecked(actor, actor, ScopeUser, values, allowImplicitDefault)
|
||
}
|
||
|
||
func (s *Service) MergeProjectAccountChecked(actor, projectID string, values map[string]any, allowImplicitDefault bool) error {
|
||
return s.mergeScopeAccountChecked(actor, actor, ScopeProjectPrefix+projectID, values, allowImplicitDefault)
|
||
}
|
||
|
||
// MergeSystemAccountChecked persists under the synthetic system owner while
|
||
// checking the real request actor's enabled state and administrator role.
|
||
func (s *Service) MergeSystemAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error {
|
||
return s.mergeScopeAccountChecked(actor, OwnerSystemRow, ScopeSystem, values, allowImplicitDefault)
|
||
}
|
||
|
||
func (s *Service) mergeScopeAccountChecked(actor, owner, scope string, values map[string]any, allowImplicitDefault bool) error {
|
||
incoming := prepareWrite(values)
|
||
if scope != ScopeSystem {
|
||
if err := s.checkManagedWrite(incoming); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.st.UpdateSettingsScopeAccountChecked(actor, owner, scope, allowImplicitDefault, func(cur map[string]any) (map[string]any, error) {
|
||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
s.snapshotThrottledAccountChecked(actor, owner, scope, allowImplicitDefault)
|
||
return nil
|
||
}
|
||
|
||
// mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。
|
||
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
|
||
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
||
incoming := prepareWrite(values)
|
||
if scope != ScopeSystem {
|
||
if err := s.checkManagedWrite(incoming); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
|
||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
s.snapshotThrottled(owner, scope)
|
||
return nil
|
||
}
|
||
|
||
// NormalizeStats 是启动迁移的结果。MaskPaths 只含 owner、scope 和键路径,不含值。
|
||
type NormalizeStats struct {
|
||
DottedKeys int
|
||
Scopes int
|
||
MaskPaths []string
|
||
}
|
||
|
||
// NormalizeStored 把已落库的点路径键展开成嵌套。嵌套对象胜出,已生效的值不变。
|
||
// 存量值恰好是掩码占位符时记入 MaskPaths,原值保留(密钥已无法恢复)。
|
||
func (s *Service) NormalizeStored() (NormalizeStats, error) {
|
||
var stats NormalizeStats
|
||
refs, err := s.st.ListSettingsScopes()
|
||
if err != nil {
|
||
return stats, err
|
||
}
|
||
for _, ref := range refs {
|
||
var dotted int
|
||
var masks []string
|
||
err := s.st.UpdateSettingsScope(ref.Owner, ref.Scope, func(cur map[string]any) (map[string]any, error) {
|
||
dotted = countDottedKeys(cur)
|
||
next := normalizeKeys(cur)
|
||
for _, p := range maskPlaceholderPaths(next, "") {
|
||
masks = append(masks, ref.Owner+" "+ref.Scope+" "+p)
|
||
}
|
||
return next, nil
|
||
})
|
||
if err != nil {
|
||
return stats, err
|
||
}
|
||
stats.MaskPaths = append(stats.MaskPaths, masks...)
|
||
if dotted > 0 {
|
||
stats.DottedKeys += dotted
|
||
stats.Scopes++
|
||
}
|
||
}
|
||
return stats, nil
|
||
}
|
||
|
||
// MaskPlaceholder 是 GET 对密钥的掩码。写回这个值视为未修改。
|
||
const MaskPlaceholder = "••••••"
|
||
|
||
// Masked 返回脱敏视图。schema 中 Type=secret 或 Secret=true 的路径一律掩码;
|
||
// 叶子键名(apiKey/apiKeys 等)再兜底一层,覆盖 schema 未声明的密钥。
|
||
// 就地掩码:调用方须传入可丢弃的 map(Effective 每次深合并产出新 map)。
|
||
func Masked(values map[string]any, schema []core.SettingDesc) map[string]any {
|
||
if values == nil {
|
||
return map[string]any{}
|
||
}
|
||
maskSecrets(values)
|
||
for _, d := range schema {
|
||
if d.Type == core.SettingSecret || d.Secret {
|
||
maskAtPath(values, d.Key)
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
func maskSecrets(m map[string]any) {
|
||
for k, v := range m {
|
||
if vm, ok := v.(map[string]any); ok {
|
||
maskSecrets(vm)
|
||
continue
|
||
}
|
||
if isSecretKey(k) {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
m[k] = MaskPlaceholder
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func isSecretKey(k string) bool {
|
||
switch k {
|
||
case "apiKey", "api_key", "apiKeys", "secret", "token":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// prepareWrite 丢弃掩码占位符,再把点路径键展开成嵌套。
|
||
func prepareWrite(values map[string]any) map[string]any {
|
||
return normalizeKeys(dropMaskPlaceholders(values))
|
||
}
|
||
|
||
// normalizeKeys 递归展开点路径键。同一层嵌套对象与点路径冲突时嵌套胜出;
|
||
// 点路径键按字典序依次合并,后写入的覆盖先写入的。不改入参。
|
||
func normalizeKeys(in map[string]any) map[string]any {
|
||
if len(in) == 0 {
|
||
return map[string]any{}
|
||
}
|
||
plain := make(map[string]any, len(in))
|
||
dotted := make([]string, 0)
|
||
for k, v := range in {
|
||
if strings.Contains(k, ".") {
|
||
dotted = append(dotted, k)
|
||
continue
|
||
}
|
||
plain[k] = normalizeValue(v)
|
||
}
|
||
sort.Strings(dotted)
|
||
expanded := map[string]any{}
|
||
for _, k := range dotted {
|
||
expanded = core.MergeSettings(expanded, expandDotted(k, normalizeValue(in[k])))
|
||
}
|
||
return core.MergeSettings(expanded, plain)
|
||
}
|
||
|
||
func normalizeValue(v any) any {
|
||
vm, ok := v.(map[string]any)
|
||
if !ok {
|
||
return v
|
||
}
|
||
return normalizeKeys(vm)
|
||
}
|
||
|
||
// expandDotted 把 "a.b.c" 收成 {a:{b:{c: val}}}。
|
||
func expandDotted(key string, val any) map[string]any {
|
||
parts := strings.Split(key, ".")
|
||
built := val
|
||
for i := len(parts) - 1; i >= 0; i-- {
|
||
built = map[string]any{parts[i]: built}
|
||
}
|
||
return built.(map[string]any)
|
||
}
|
||
|
||
func dropMaskPlaceholders(m map[string]any) map[string]any {
|
||
out := make(map[string]any, len(m))
|
||
for k, v := range m {
|
||
if vm, ok := v.(map[string]any); ok {
|
||
out[k] = dropMaskPlaceholders(vm)
|
||
continue
|
||
}
|
||
if s, ok := v.(string); ok && s == MaskPlaceholder {
|
||
continue
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
func countDottedKeys(m map[string]any) int {
|
||
n := 0
|
||
for k, v := range m {
|
||
if strings.Contains(k, ".") {
|
||
n++
|
||
}
|
||
if vm, ok := v.(map[string]any); ok {
|
||
n += countDottedKeys(vm)
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func maskPlaceholderPaths(m map[string]any, prefix string) []string {
|
||
var out []string
|
||
for k, v := range m {
|
||
path := k
|
||
if prefix != "" {
|
||
path = prefix + "." + k
|
||
}
|
||
if vm, ok := v.(map[string]any); ok {
|
||
out = append(out, maskPlaceholderPaths(vm, path)...)
|
||
continue
|
||
}
|
||
if s, ok := v.(string); ok && s == MaskPlaceholder {
|
||
out = append(out, path)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// maskAtPath 掩码 schema 路径。路径作为单个点键存在时同样掩码。
|
||
func maskAtPath(m map[string]any, path string) {
|
||
if s, ok := m[path].(string); ok && s != "" {
|
||
m[path] = MaskPlaceholder
|
||
}
|
||
parts := strings.Split(path, ".")
|
||
cur := m
|
||
for i, p := range parts {
|
||
v, ok := cur[p]
|
||
if !ok {
|
||
return
|
||
}
|
||
if i == len(parts)-1 {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
cur[p] = MaskPlaceholder
|
||
}
|
||
return
|
||
}
|
||
next, ok := v.(map[string]any)
|
||
if !ok {
|
||
return
|
||
}
|
||
cur = next
|
||
}
|
||
}
|