- 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)
199 lines
6.4 KiB
Go
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
|
|
}
|
|
}
|