Files
Agent-System/internal/sysadmin/settings.go
T
weichen f1404d5bd2 feat(sysadmin): add system admin aggregation API and system_settings store
- new internal/sysadmin package (overview/kb/features/settings) with small
  port interfaces; business packages stay unaware of sysadmin
- new routes /api/v1/admin/system/{overview,kb,features,settings} guarded
  by requireSystemAdmin
- goose 00049: system_settings table (key/jsonb value, updated_by/at)
- SettingSpec whitelist registry with DB > env > default precedence
- register sysadmin in module-boundaries.yaml (archtest rules)
2026-09-17 06:56:46 +00:00

199 lines
6.4 KiB
Go

package sysadmin
import (
"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")
)
// 设置值类型。
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"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
UpdatedBy *int64 `json:"updatedBy,omitempty"`
}
// defaultSettingSpecs 一期白名单:先做机制,键空间可后续扩展。
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 提醒的总开关(一期先落机制)"},
}
}
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) {
spec, ok := s.findSpec(key)
if !ok {
return Setting{}, ErrUnknownSetting
}
if !spec.Writable {
return Setting{}, ErrSettingReadOnly
}
if err := validateSettingValue(spec.Type, raw); err != nil {
return Setting{}, err
}
if s.store == nil {
return Setting{}, ErrStoreUnavailable
}
if _, err := s.store.Upsert(ctx, key, raw, 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, 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.UpdatedBy = updatedBy
setting.UpdatedAt = updatedAt
return setting, nil
}
// validateSettingValue 按声明类型校验 JSON 值。
func validateSettingValue(valueType string, raw json.RawMessage) error {
if len(raw) == 0 {
return ErrInvalidSettingValue
}
switch valueType {
case SettingTypeString:
var v string
if err := json.Unmarshal(raw, &v); err != nil {
return ErrInvalidSettingValue
}
case SettingTypeBool:
var v bool
if err := json.Unmarshal(raw, &v); err != nil {
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
}
}