Files
Agent-System/internal/sysadmin/settings.go
T

253 lines
9.0 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"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"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")
)
// 设置值类型。
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
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"`
Description string `json:"description"`
Revision int64 `json:"revision"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
UpdatedBy *int64 `json:"updatedBy,omitempty"`
}
// SettingUpdate 写入入参;仅版本化策略 key 要求 ExpectedRevision。
type SettingUpdate struct {
Key string
Value json.RawMessage
UpdatedBy int64
ExpectedRevision *int64
RequestID string
}
// defaultSettingSpecs 一期白名单:先做机制,键空间可后续扩展。
//
// Wave 5 X-4: 除 SettingKeyAllowAuthorOnlyApproval 已接线外,下列 6 键暂无运行时
// 消费方;决策 = 保留白名单待系统管理台二期接线(FEATURE-MATRIX「白名单项接消费方」),
// 不从白名单删除。见 .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-4。
// 预期消费方(二期):
// - ui.maintenance_notice → 站点顶部维护公告横幅
// - ui.read_only_mode → 写操作中间件 / 前端写入口阻断
// - chat.default_session_role → 会话创建默认职责
// - tasks.default_auto_repair → 独立任务运行默认 auto_repair
// - kb.default_search_limit → 知识库检索默认 limit
// - notifications.stage_advance_reminder → 阶段就绪提醒 ticker 总开关
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: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。",
},
}
}
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 校验并写入白名单设置,返回更新后的视图。
// 兼容旧调用:非版本化 key 可走此入口;版本化策略 key 必须带 ExpectedRevision,请用 UpdateSettingEx。
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})
}
// 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 err := validateSettingValue(spec.Type, in.Value); err != nil {
return Setting{}, err
}
if s.store == nil {
return Setting{}, ErrStoreUnavailable
}
if in.Key == SettingKeyAllowAuthorOnlyApproval {
if in.ExpectedRevision == nil {
return Setting{}, ErrExpectedRevisionRequired
}
if _, _, err := s.store.UpsertCAS(ctx, in.Key, in.Value, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil {
return Setting{}, err
}
} else {
if _, _, err := s.store.Upsert(ctx, in.Key, in.Value, in.UpdatedBy); err != nil {
return Setting{}, err
}
}
return s.resolve(ctx, spec)
}
// 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,
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 setting, nil
}
raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return setting, 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 setting, nil
}
// 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
}
// 拒绝尾随垃圾(如 truefalse)
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
}
}