Files
Agent-System-Backend/internal/settings/settings.go
T
weichenandCursor cfde38de31 Send current-turn images and keep vision fallbacks capable
Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see.

Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests.

Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way.

Refs: Agent-System-Dev feature/four-model-vision
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:51:17 +00:00

458 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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": ""},
"vision": map[string]any{
"model": "qwen3.8-flash",
"capable": "deepseek-v4.1-flash,qwen3.8-flash,ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash",
},
"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.vision.model", Group: "models", Label: "视觉兜底模型", Type: core.SettingString, Default: "qwen3.8-flash", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "当前轮含图片且主模型不在视觉清单内时改用此模型。端点与密钥沿用主模型"},
{Key: "models.vision.capable", Group: "models", Label: "可看图模型", Type: core.SettingString, Default: "deepseek-v4.1-flash,qwen3.8-flash,ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "逗号分隔。当前轮有图片时,主模型和回退链只保留清单内的名字。空则使用内置默认清单"},
{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)
}
// 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
}
}