Seed fine-grained console permissions (goose 00065), gate admin handlers by system:* keys, and persist settings change audits for all upserts. KB ops and whitelist consumers stay deferred.
267 lines
9.4 KiB
Go
267 lines
9.4 KiB
Go
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})
|
||
}
|
||
|
||
// 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 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
|
||
}
|
||
}
|