package sysadmin import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "strconv" "strings" "time" ) // 设置读写错误;HTTP 适配层据此映射状态码。 var ( ErrUnknownSetting = errors.New("sysadmin: unknown setting key") ErrSettingReadOnly = errors.New("sysadmin: setting is read-only") ErrInvalidSettingValue = errors.New("sysadmin: invalid setting value") ErrStoreUnavailable = errors.New("sysadmin: settings store unavailable") ErrExpectedRevisionRequired = errors.New("sysadmin: expected_revision required") ErrSecretUnchanged = errors.New("sysadmin: secret empty means keep existing") ) // 设置值类型。 const ( SettingTypeString = "string" SettingTypeBool = "bool" SettingTypeNumber = "number" ) // SettingSpec 白名单注册表条目;只有登记在册的 key 才可读写。 type SettingSpec struct { Key string Label string Group string Type string Default any Writable bool RequiresRestart bool Secret bool // true 时 API 只回掩码,审计只存指纹 HotReload bool // true 时保存后可推送到 PI runner 热更新 EnvVar string // 可选 env 中间层:DB > env > default Description string } // Setting 设置视图,字段对齐前端 camelCase 契约。 type Setting struct { Key string `json:"key"` Label string `json:"label"` Group string `json:"group"` Type string `json:"type"` Value any `json:"value"` Default any `json:"default"` Writable bool `json:"writable"` RequiresRestart bool `json:"requiresRestart"` Secret bool `json:"secret,omitempty"` HotReload bool `json:"hotReload,omitempty"` HasValue bool `json:"hasValue,omitempty"` Masked string `json:"masked,omitempty"` Description string `json:"description"` Revision int64 `json:"revision"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedBy *int64 `json:"updatedBy,omitempty"` ApplyStatus string `json:"applyStatus,omitempty"` // ok | pending | error | n/a ApplyError string `json:"applyError,omitempty"` } // SettingUpdate 写入入参;仅版本化策略 key 要求 ExpectedRevision。 type SettingUpdate struct { Key string Value json.RawMessage UpdatedBy int64 ExpectedRevision *int64 RequestID string } // defaultSettingSpecs 白名单:UI/业务策略 + agent/secrets(.env 类)。 // // Wave 5 X-4: ui.* / chat.* / tasks.* / kb.* / notifications.* 部分键待消费方接线。 // 预期消费方: // - ui.maintenance_notice → 站点顶部维护公告横幅(P2 maintenance 接管) // - ui.read_only_mode → 写操作中间件 / 前端写入口阻断 func defaultSettingSpecs() []SettingSpec { return []SettingSpec{ {Key: "ui.maintenance_notice", Label: "维护公告", Group: "ui", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, Description: "全站维护公告横幅文案;空字符串表示关闭"}, {Key: "ui.read_only_mode", Label: "只读模式提示", Group: "ui", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "开启后前端进入只读提示模式,阻断写操作入口"}, {Key: "chat.default_session_role", Label: "新会话默认职责", Group: "chat", Type: SettingTypeString, Default: "engineer", Writable: true, RequiresRestart: false, Description: "未显式指定职责时新会话的默认角色"}, {Key: "tasks.default_auto_repair", Label: "任务默认自动修复", Group: "tasks", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "独立任务运行未传 auto_repair 时的默认值"}, {Key: "kb.default_search_limit", Label: "KB 检索默认条数", Group: "kb", Type: SettingTypeNumber, Default: 8.0, Writable: true, RequiresRestart: false, Description: "知识库检索未指定 limit 时的默认返回条数"}, {Key: "notifications.stage_advance_reminder", Label: "阶段推进提醒总开关", Group: "notification", Type: SettingTypeBool, Default: true, Writable: true, RequiresRestart: false, Description: "阶段就绪 24h 提醒的总开关(一期先落机制)"}, { Key: SettingKeyAllowAuthorOnlyApproval, Label: "允许编制人兼任审核批准", Group: "workflow", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。", }, // agent 组:模型与端点(可热推 runner) {Key: "agent.minimax_endpoint_url", Label: "MiniMax Endpoint", Group: "agent", Type: SettingTypeString, Default: "https://api.minimaxi.com/v1", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_ENDPOINT_URL", Description: "MiniMax API 基址"}, {Key: "agent.minimax_model_id", Label: "MiniMax 模型 ID", Group: "agent", Type: SettingTypeString, Default: "MiniMax-M2.7-highspeed", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_MODEL_ID", Description: "默认模型 ID"}, {Key: "agent.minimax_api_type", Label: "MiniMax API 类型", Group: "agent", Type: SettingTypeString, Default: "openai-compatible", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_API_TYPE", Description: "openai-compatible 或原生类型"}, {Key: "agent.pi_agent_model", Label: "PI Agent 模型", Group: "agent", Type: SettingTypeString, Default: "MiniMax-M2.7-highspeed", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "PI_AGENT_MODEL", Description: "宿主侧 PI Agent 模型名"}, // secrets 组:密钥(掩码 + 热推或部分需重启) {Key: "secrets.minimax_api_key", Label: "MiniMax API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, HotReload: true, Secret: true, EnvVar: "MINIMAX_API_KEY", Description: "MiniMax API 密钥;空保存表示保持原值"}, {Key: "secrets.tavily_api_keys", Label: "Tavily API Keys", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, HotReload: true, Secret: true, EnvVar: "TAVILY_API_KEYS", Description: "逗号分隔的 Tavily key 池;空保存表示保持原值"}, {Key: "secrets.embedding_api_key", Label: "Embedding API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "EMBEDDING_API_KEY", Description: "向量 Embedding 密钥(需重启 Go 宿主)"}, {Key: "secrets.reranker_api_key", Label: "Reranker API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "RERANKER_API_KEY", Description: "Reranker 密钥(需重启 Go 宿主)"}, {Key: "secrets.mineru_api_key", Label: "MinerU API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "MINERU_API_KEY", Description: "MinerU 文档解析密钥(需重启 Go 宿主)"}, } } func (s *Service) findSpec(key string) (SettingSpec, bool) { for _, spec := range s.specs { if spec.Key == key { return spec, true } } return SettingSpec{}, false } // ListSettings 返回白名单全部设置;读优先级 DB > env > default。 func (s *Service) ListSettings(ctx context.Context) ([]Setting, error) { out := make([]Setting, 0, len(s.specs)) for _, spec := range s.specs { setting, err := s.resolve(ctx, spec) if err != nil { return nil, err } out = append(out, setting) } return out, nil } // UpdateSetting 校验并写入白名单设置,返回更新后的视图。 func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMessage, updatedBy int64) (Setting, error) { return s.UpdateSettingEx(ctx, SettingUpdate{Key: key, Value: raw, UpdatedBy: updatedBy}) } // ListPolicyAudits 返回指定设置键的策略变更审计(最新在前)。 func (s *Service) ListPolicyAudits(ctx context.Context, key string, limit int) ([]PolicyAudit, error) { if key == "" { return nil, ErrUnknownSetting } if _, ok := s.findSpec(key); !ok { return nil, ErrUnknownSetting } if s.store == nil { return nil, ErrStoreUnavailable } return s.store.ListPolicyAudits(ctx, key, limit) } // UpdateSettingEx 校验并写入白名单设置;对 SettingKeyAllowAuthorOnlyApproval 强制 CAS + 同事务审计。 func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Setting, error) { spec, ok := s.findSpec(in.Key) if !ok { return Setting{}, ErrUnknownSetting } if !spec.Writable { return Setting{}, ErrSettingReadOnly } if s.store == nil { return Setting{}, ErrStoreUnavailable } raw := in.Value if spec.Secret { keep, err := secretKeepExisting(raw) if err != nil { return Setting{}, err } if keep { return s.resolve(ctx, spec) } } if err := validateSettingValue(spec.Type, raw); err != nil { return Setting{}, err } if in.Key == SettingKeyAllowAuthorOnlyApproval { if in.ExpectedRevision == nil { return Setting{}, ErrExpectedRevisionRequired } if _, _, err := s.store.UpsertCAS(ctx, in.Key, raw, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil { return Setting{}, err } } else { auditAfter := raw if spec.Secret { auditAfter = secretFingerprintJSON(raw) } if _, _, err := s.store.UpsertWithAudit(ctx, in.Key, raw, auditAfter, in.UpdatedBy, in.RequestID); err != nil { return Setting{}, err } } setting, err := s.resolve(ctx, spec) if err != nil { return Setting{}, err } if spec.HotReload && s.applyFn != nil { status, applyErr := s.applyFn(ctx) setting.ApplyStatus = status if applyErr != nil { setting.ApplyError = applyErr.Error() } } else if spec.RequiresRestart { setting.ApplyStatus = "pending_restart" } else { setting.ApplyStatus = "ok" } return setting, nil } // GetSettingValue 返回白名单 key 的当前生效值(PG 覆盖 → env 覆盖 → 默认)。 // 供维护横幅 / 只读提示等运行时消费方只读使用;非白名单 key 返回 ErrUnknownSetting。 func (s *Service) GetSettingValue(ctx context.Context, key string) (any, error) { spec, ok := s.findSpec(key) if !ok { return nil, ErrUnknownSetting } setting, err := s.resolve(ctx, spec) if err != nil { return nil, err } return setting.Value, nil } // resolve 按 DB > env > default 解析单个设置的当前值。 func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error) { setting := Setting{ Key: spec.Key, Label: spec.Label, Group: spec.Group, Type: spec.Type, Value: spec.Default, Default: spec.Default, Writable: spec.Writable, RequiresRestart: spec.RequiresRestart, Secret: spec.Secret, HotReload: spec.HotReload, Description: spec.Description, } if spec.EnvVar != "" { if raw, set := os.LookupEnv(spec.EnvVar); set { if v, err := parseSettingValue(spec.Type, raw); err == nil { setting.Value = v } } } if s.store == nil { return finalizeSecretView(setting, spec), nil } raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key) if err != nil { if errors.Is(err, ErrSettingNotFound) { return finalizeSecretView(setting, spec), nil } return Setting{}, err } var value any if err := json.Unmarshal(raw, &value); err != nil { return Setting{}, fmt.Errorf("sysadmin: decode stored value for %s: %w", spec.Key, err) } setting.Value = value setting.Revision = revision setting.UpdatedBy = updatedBy setting.UpdatedAt = updatedAt return finalizeSecretView(setting, spec), nil } // ResolveRawString 返回未掩码的字符串值(供 runner 推送);secret 也返回明文。 func (s *Service) ResolveRawString(ctx context.Context, key string) (string, error) { spec, ok := s.findSpec(key) if !ok { return "", ErrUnknownSetting } if s.store != nil { raw, _, _, _, err := s.store.Get(ctx, key) if err == nil { var v string if err := json.Unmarshal(raw, &v); err == nil { return v, nil } } else if !errors.Is(err, ErrSettingNotFound) { return "", err } } if spec.EnvVar != "" { if raw, set := os.LookupEnv(spec.EnvVar); set { return raw, nil } } if s, ok := spec.Default.(string); ok { return s, nil } return "", nil } func finalizeSecretView(setting Setting, spec SettingSpec) Setting { if !spec.Secret { return setting } plain := "" switch v := setting.Value.(type) { case string: plain = v case nil: plain = "" default: b, _ := json.Marshal(v) plain = string(b) } setting.HasValue = strings.TrimSpace(plain) != "" setting.Masked = MaskSecret(plain) setting.Value = "" return setting } // MaskSecret 展示前缀/后缀,中间打码。 func MaskSecret(plain string) string { plain = strings.TrimSpace(plain) if plain == "" { return "" } // 多 key 池:分别掩码后拼接 if strings.ContainsAny(plain, ",;\n") { parts := strings.FieldsFunc(plain, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == ' ' }) masked := make([]string, 0, len(parts)) for _, p := range parts { if p == "" { continue } masked = append(masked, maskOne(p)) } return strings.Join(masked, ",") } return maskOne(plain) } func maskOne(s string) string { if len(s) < 8 { return "****" } return s[:4] + "..." + s[len(s)-4:] } func secretKeepExisting(raw json.RawMessage) (bool, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || string(trimmed) == "null" { return true, nil } var v string if err := json.Unmarshal(raw, &v); err != nil { return false, ErrInvalidSettingValue } return strings.TrimSpace(v) == "", nil } func secretFingerprintJSON(raw json.RawMessage) json.RawMessage { var v string if err := json.Unmarshal(raw, &v); err != nil { sum := sha256.Sum256(raw) out, _ := json.Marshal(map[string]string{"sha256": hex.EncodeToString(sum[:])}) return out } sum := sha256.Sum256([]byte(v)) out, _ := json.Marshal(map[string]string{"sha256": hex.EncodeToString(sum[:])}) return out } // validateSettingValue 按声明类型校验 JSON 值。 func validateSettingValue(valueType string, raw json.RawMessage) error { if len(raw) == 0 { return ErrInvalidSettingValue } trimmed := bytes.TrimSpace(raw) if string(trimmed) == "null" { return ErrInvalidSettingValue } switch valueType { case SettingTypeString: var v string if err := json.Unmarshal(raw, &v); err != nil { return ErrInvalidSettingValue } case SettingTypeBool: var v bool dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&v); err != nil { return ErrInvalidSettingValue } if dec.More() { return ErrInvalidSettingValue } case SettingTypeNumber: var v float64 if err := json.Unmarshal(raw, &v); err != nil { return ErrInvalidSettingValue } default: return ErrInvalidSettingValue } return nil } // parseSettingValue 将 env 字符串按类型解析。 func parseSettingValue(valueType, raw string) (any, error) { switch valueType { case SettingTypeString: return raw, nil case SettingTypeBool: v, err := strconv.ParseBool(raw) if err != nil { return nil, err } return v, nil case SettingTypeNumber: v, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, err } return v, nil default: return nil, ErrInvalidSettingValue } }