Files
Agent-System/internal/sysadmin/settings.go
T
weichen 86e9d3706b feat(sysadmin): admin env settings + maintenance FD handoff
Persist agent/secrets settings with masked secrets and runner hot-reload;
add maintenance windows, maintpage binary, and zero-downtime listener handoff.
2026-09-24 23:20:20 +00:00

419 lines
15 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 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
}
// 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
}
}