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)
This commit is contained in:
weichen
2026-09-17 06:56:46 +00:00
parent 173a512c28
commit f1404d5bd2
13 changed files with 1311 additions and 4 deletions
@@ -90,6 +90,7 @@ imports:
- taskrun
- workflowdef
- workspace
- sysadmin
app/chatflow:
- coordinator
- ctxmgr
@@ -202,6 +203,7 @@ imports:
- workflowdef
- workspace
- artifactvault
- sysadmin
squad:
- agent
- config
@@ -244,6 +246,8 @@ imports:
dataset:
- artifactvault
- app/uow
sysadmin:
- config
table_owners:
document_records:
@@ -326,6 +330,8 @@ table_owners:
- app/intentflow
intent_plan_shadow:
- app/intentflow
system_settings:
- sysadmin
path_owners:
output.md:
@@ -0,0 +1,12 @@
-- +goose Up
-- 系统管理台(一期):白名单化系统设置存储。键空间由 internal/sysadmin SettingSpec 注册表约束。
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_by BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS system_settings;
+49
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
@@ -33,6 +34,7 @@ import (
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/storage"
"chat-agent-system/internal/sysadmin"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workspace"
@@ -73,6 +75,7 @@ type Runtime struct {
CAS *storage.CAS
FreeDoc *freedoc.Service
KB *kb.Client
SysAdmin *sysadmin.Service
mu sync.Mutex
running bool
@@ -208,6 +211,15 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
FreeDoc: parts.FreeDoc,
KB: parts.KB,
}
rt.SysAdmin = sysadmin.NewService(cfg, sysadmin.Options{
Pool: pool,
Status: svc,
KB: parts.KB,
TaskStoreReady: func(ctx context.Context) bool {
return taskrun.TablesReady(ctx, pool)
},
FeatureValues: sysadminFeatureValues(cfg),
})
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov}
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
return rt.WriterLive(ctx, events.ConsumerNotify)
@@ -257,6 +269,43 @@ func (r *Runtime) bindWorkflowApprovalHook() {
r.hookBound = true
}
// sysadminFeatureValues 在组合根解析各业务包公开 flag 的生效值,供 sysadmin 聚合层
// 只读消费;sysadmin 不 import 业务包。
func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
return func() map[string]string {
iflags := intent.CurrentFeatureFlags()
pflags := intentflow.CurrentFeatureFlags()
sflags := ctxmgr.CurrentSummaryFlags()
values := map[string]string{
"INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow),
"INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision),
"INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier),
"WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled),
"MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled),
"SEMANTIC_FALLBACK_ENABLED": strconv.FormatBool(iflags.SemanticFallbackEnabled),
"MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier),
"MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment),
"MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate),
"MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov),
"MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl),
"SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow),
"SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace),
"PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()),
}
if cfg != nil {
values["AGENT_BACKEND"] = cfg.AgentBackend
values["MAX_CONCURRENT_AGENTS"] = strconv.Itoa(cfg.MaxConcurrentAgents)
values["PI_AGENT_RUNNER_URL"] = cfg.PiAgentRunnerURL
values["ALLOW_IN_MEMORY"] = strconv.FormatBool(cfg.AllowInMemory)
values["AUTH_REGISTRATION_ENABLED"] = strconv.FormatBool(cfg.AuthRegistrationEnabled)
values["AUTH_REQUIRE_APPROVAL"] = strconv.FormatBool(cfg.AuthRequireApproval)
values["FAV_PUBLISHER_ENABLED"] = strconv.FormatBool(cfg.FAVPublisherEnabled)
values["FAV_DEGRADED_READ"] = strconv.FormatBool(cfg.FAVDegradedRead)
}
return values
}
}
// WriterLive reports whether the durable consumer already owns the visible write.
func (r *Runtime) WriterLive(ctx context.Context, consumer string) bool {
if r == nil {
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"chat-agent-system/internal/sysadmin"
)
// 系统管理台(一期):全部端点要求系统管理员。
func (s *Server) handleAdminSystemOverview(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
if s.sysadmin == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable")
return
}
writeJSONResponse(w, http.StatusOK, s.sysadmin.Overview(r.Context()))
}
func (s *Server) handleAdminSystemKB(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
if s.sysadmin == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable")
return
}
writeJSONResponse(w, http.StatusOK, s.sysadmin.KBInfo())
}
func (s *Server) handleAdminSystemFeatures(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
if s.sysadmin == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable")
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"features": s.sysadmin.Features()})
}
func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
if s.sysadmin == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable")
return
}
settings, err := s.sysadmin.ListSettings(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireSystemAdmin(w, r)
if !ok {
return
}
if s.sysadmin == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable")
return
}
key := r.PathValue("key")
var req struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return
}
setting, err := s.sysadmin.UpdateSetting(r.Context(), key, req.Value, user.ID)
switch {
case errors.Is(err, sysadmin.ErrUnknownSetting):
writeSafeError(w, r, http.StatusBadRequest, "未知的设置键")
case errors.Is(err, sysadmin.ErrSettingReadOnly):
writeSafeError(w, r, http.StatusForbidden, "该设置为只读")
case errors.Is(err, sysadmin.ErrInvalidSettingValue):
writeSafeError(w, r, http.StatusBadRequest, "设置值类型不匹配")
case errors.Is(err, sysadmin.ErrStoreUnavailable):
writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用")
case err != nil:
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
default:
writeJSONResponse(w, http.StatusOK, setting)
}
}
@@ -0,0 +1,244 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/sysadmin"
)
type fakeSettingsStore struct {
mu sync.Mutex
items map[string]json.RawMessage
}
func newFakeSettingsStore() *fakeSettingsStore {
return &fakeSettingsStore{items: map[string]json.RawMessage{}}
}
func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
raw, ok := f.items[key]
if !ok {
return nil, nil, nil, sysadmin.ErrSettingNotFound
}
at := time.Now()
by := int64(1)
return raw, &by, &at, nil
}
func (f *fakeSettingsStore) Upsert(_ context.Context, key string, value json.RawMessage, _ int64) (time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.items[key] = append(json.RawMessage(nil), value...)
return time.Now(), nil
}
// registerTestUser 注册并登录一个普通用户,返回其 Bearer Token。
func registerTestUser(t *testing.T, srv *Server, username string) string {
t.Helper()
if err := srv.AuthService().UpdateConfig(context.Background(), auth.SystemAuthConfig{
AllowRegistration: true,
RequireApproval: false,
}); err != nil {
t.Fatalf("enable registration: %v", err)
}
body, _ := json.Marshal(map[string]string{
"username": username,
"password": "PlainPass123",
"name": "普通用户",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("register %s failed: %d %s", username, w.Code, w.Body.String())
}
loginBody, _ := json.Marshal(map[string]string{"username": username, "password": "PlainPass123"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login %s failed: %d %s", username, w.Code, w.Body.String())
}
var session auth.SessionInfo
if err := json.NewDecoder(w.Body).Decode(&session); err != nil || session.Token == "" {
t.Fatalf("decode login session: %v", err)
}
return session.Token
}
func doSysadminRequest(t *testing.T, srv *Server, method, path, token, body string) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if token != "" {
withBearerAuth(req, token)
}
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
return w
}
func TestAdminSystemEndpoints_RequireSystemAdmin(t *testing.T) {
srv := newTestServer(t)
srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权
plainToken := registerTestUser(t, srv, "plain_sysadmin_probe")
endpoints := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/admin/system/overview"},
{http.MethodGet, "/api/v1/admin/system/kb"},
{http.MethodGet, "/api/v1/admin/system/features"},
{http.MethodGet, "/api/v1/admin/system/settings"},
{http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice"},
}
for _, ep := range endpoints {
// 未认证 → 401
if w := doSysadminRequest(t, srv, ep.method, ep.path, "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("%s %s: expected 401 without auth, got %d", ep.method, ep.path, w.Code)
}
// 非管理员 → 403
if w := doSysadminRequest(t, srv, ep.method, ep.path, plainToken, ""); w.Code != http.StatusForbidden {
t.Fatalf("%s %s: expected 403 for non-admin, got %d %s", ep.method, ep.path, w.Code, w.Body.String())
}
}
}
func TestAdminSystemOverview_AdminGetsCamelCasePayload(t *testing.T) {
srv := newTestServer(t)
srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权
token := loginTestAdmin(t, srv)
w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/overview", token, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
for _, key := range []string{"status", "env", "agentBackend", "databaseReady", "schemaVersion", "taskStoreReady", "kbOnline", "uptimeSeconds"} {
if _, ok := body[key]; !ok {
t.Fatalf("overview missing camelCase key %s: %v", key, body)
}
}
if body["env"] != "test" || body["agentBackend"] != "stub" {
t.Fatalf("unexpected overview payload: %v", body)
}
}
func TestAdminSystemKB_NoSecretMaterial(t *testing.T) {
srv := newTestServer(t)
srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权
token := loginTestAdmin(t, srv)
w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/kb", token, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
for _, key := range []string{"online", "host", "port", "database", "user", "embeddingModel", "rerankerEnabled", "sslMode"} {
if _, ok := body[key]; !ok {
t.Fatalf("kb payload missing key %s: %v", key, body)
}
}
if bytes.Contains(w.Body.Bytes(), []byte("assword")) {
t.Fatalf("kb payload leaks secret material: %s", w.Body.String())
}
}
func TestAdminSystemFeatures_Payload(t *testing.T) {
srv := newTestServer(t)
srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权
token := loginTestAdmin(t, srv)
w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/features", token, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String())
}
var body struct {
Features []sysadmin.Feature `json:"features"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode features: %v", err)
}
found := false
for _, f := range body.Features {
if f.Key == "FAV_PUBLISHER_ENABLED" && f.Group == "artifact-vault" && f.Source == "env" {
found = true
}
}
if !found {
t.Fatal("features payload must include FAV_PUBLISHER_ENABLED in artifact-vault group")
}
}
func TestAdminSystemSettings_ListAndUpdate(t *testing.T) {
srv := newTestServer(t)
srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权
// 用带内存 store 的 sysadmin 服务替换装配产物(无 DB 时 store 为 nil)。
srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: newFakeSettingsStore()})
token := loginTestAdmin(t, srv)
w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/settings", token, "")
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d %s", w.Code, w.Body.String())
}
var list struct {
Settings []sysadmin.Setting `json:"settings"`
}
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list.Settings) == 0 {
t.Fatal("expected whitelist settings")
}
// 合法写 → 200,返回更新后的 setting
w = doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice", token, `{"value":"停机维护"}`)
if w.Code != http.StatusOK {
t.Fatalf("update: expected 200, got %d %s", w.Code, w.Body.String())
}
var updated sysadmin.Setting
if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil {
t.Fatalf("decode updated: %v", err)
}
if updated.Value != "停机维护" || !updated.Writable {
t.Fatalf("unexpected updated setting: %+v", updated)
}
// 未知 key → 400
if w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/no.such.key", token, `{"value":"x"}`); w.Code != http.StatusBadRequest {
t.Fatalf("unknown key: expected 400, got %d %s", w.Code, w.Body.String())
}
// 类型不匹配 → 400
if w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.read_only_mode", token, `{"value":"yes"}`); w.Code != http.StatusBadRequest {
t.Fatalf("type mismatch: expected 400, got %d %s", w.Code, w.Body.String())
}
}
func TestAdminSystemSettings_UpdateWithoutStoreReturns503(t *testing.T) {
srv := newTestServer(t) // 无 DB:sysadmin store 为 nil
token := loginTestAdmin(t, srv)
w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice", token, `{"value":"x"}`)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 without settings store, got %d %s", w.Code, w.Body.String())
}
}
+14 -4
View File
@@ -31,6 +31,7 @@ import (
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/storage"
"chat-agent-system/internal/sysadmin"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workspace"
@@ -68,16 +69,17 @@ type Server struct {
pool *pgxpool.Pool
freeDoc *freedoc.Service
kb *kb.Client
sysadmin *sysadmin.Service
httpServer *http.Server
intentPlans intentflow.Service
planAccessFn func(ctx context.Context, projectID, userID int64) error
planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error)
// FAV stubs (U7 incremental): nil / off unless FAV_PUBLISHER_ENABLED.
vaultReg artifactvault.Registry
vaultStore *artifactvault.FilesystemStore
vaultPub *artifactvault.Publisher
vaultPublisherStarted int32
vaultReg artifactvault.Registry
vaultStore *artifactvault.FilesystemStore
vaultPub *artifactvault.Publisher
vaultPublisherStarted int32
}
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
@@ -111,6 +113,7 @@ func NewFromRuntime(rt *app.Runtime) *Server {
pool: rt.Pool,
freeDoc: rt.FreeDoc,
kb: rt.KB,
sysadmin: rt.SysAdmin,
intentPlans: rt.IntentPlans,
}
s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz())
@@ -323,6 +326,13 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig)
mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig)
// 9.1 系统管理台(一期):只读聚合 + 白名单设置
mux.HandleFunc("GET /api/v1/admin/system/overview", s.handleAdminSystemOverview)
mux.HandleFunc("GET /api/v1/admin/system/kb", s.handleAdminSystemKB)
mux.HandleFunc("GET /api/v1/admin/system/features", s.handleAdminSystemFeatures)
mux.HandleFunc("GET /api/v1/admin/system/settings", s.handleAdminSystemSettings)
mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey)
// 10. 现代 SPA 前端页面与静态资产托管
mux.HandleFunc("GET /", s.serveSPA)
+78
View File
@@ -0,0 +1,78 @@
package sysadmin
import "os"
// Feature 功能开关视图;value 统一用字符串表示。
type Feature struct {
Key string `json:"key"`
Label string `json:"label"`
Group string `json:"group"`
Value string `json:"value"`
Source string `json:"source"`
RequiresRestart bool `json:"requiresRestart"`
Description string `json:"description"`
}
type featureSpec struct {
key string // 环境变量名
label string
group string
fallback string // 组合根未提供生效值时的兜底
description string
}
// featureCatalog 覆盖 config.Config 主要开关与各业务包灰度 flag(宁全勿缺)。
// 生效值由组合根(app)解析后注入,避免本包反向依赖业务模块。
var featureCatalog = []featureSpec{
{key: "AGENT_BACKEND", label: "Agent 执行后端", group: "agent", fallback: "pi-agent", description: "对话与任务执行的 Agent 后端(stub / cursor / pi-agent)"},
{key: "MAX_CONCURRENT_AGENTS", label: "Agent 全局并发上限", group: "agent", fallback: "8", description: "保护 pi-agent / GPU / API 的并发闸"},
{key: "PI_AGENT_RUNNER_URL", label: "PI Runner 地址", group: "agent", fallback: "", description: "产品内 PI Agent Runner 服务地址;空表示未接入"},
{key: "ALLOW_IN_MEMORY", label: "允许内存降级模式", group: "system", fallback: "false", description: "数据库不可用时降级内存模式(仅限本地 Demo)"},
{key: "AUTH_REGISTRATION_ENABLED", label: "开放注册", group: "auth", fallback: "true", description: "是否允许用户自主注册"},
{key: "AUTH_REQUIRE_APPROVAL", label: "注册需审核", group: "auth", fallback: "false", description: "新注册用户是否需要管理员审核后激活"},
{key: "FAV_PUBLISHER_ENABLED", label: "FAV 发布器", group: "artifact-vault", fallback: "false", description: "Final Artifact Vault 后台发布器;默认关闭"},
{key: "FAV_DEGRADED_READ", label: "FAV 降级读", group: "artifact-vault", fallback: "false", description: "FAV 缺失时允许降级读取;默认关闭"},
{key: "INTENT_V2_SHADOW", label: "意图 V2 影子模式", group: "intent", fallback: "false", description: "V2 意图识别影子运行,仅观测不接管"},
{key: "INTENT_V2_DECISION", label: "意图 V2 决策", group: "intent", fallback: "false", description: "V2 意图识别接管路由决策"},
{key: "INTENT_V2_DECISION_TIER", label: "意图 V2 决策档位", group: "intent", fallback: "none", description: "V2 灰度风险档位:none/query/generate/write_low/write_high/all"},
{key: "WORKFLOW_INTENTS_ENABLED", label: "工作流意图", group: "intent", fallback: "true", description: "识别工作流类意图"},
{key: "MULTI_INTENT_ENABLED", label: "多意图识别", group: "intent", fallback: "true", description: "单条消息多意图拆解"},
{key: "SEMANTIC_FALLBACK_ENABLED", label: "语义兜底", group: "intent", fallback: "false", description: "规则意图未命中时的语义兜底"},
{key: "MULTI_INTENT_PLAN_TIER", label: "多意图计划档位", group: "intentflow", fallback: "legacy", description: "意图计划灰度:legacy/shadow/auto_read/confirm/full"},
{key: "MULTI_INTENT_U9_SEGMENT", label: "U9 LLM 分段", group: "intentflow", fallback: "false", description: "LLM 意图分段提议"},
{key: "MULTI_INTENT_U10_MUTATE", label: "U10 变更适配器", group: "intentflow", fallback: "false", description: "低/中风险 mutate 动作适配器"},
{key: "MULTI_INTENT_U11_DOCGOV", label: "U11 文档治理适配器", group: "intentflow", fallback: "false", description: "docgov 动作适配器"},
{key: "MULTI_INTENT_U12_RUN_CONTROL", label: "U12 运行控制适配器", group: "intentflow", fallback: "false", description: "workflow/run-control 动作适配器"},
{key: "SEMANTIC_SUMMARY_SHADOW", label: "语义摘要影子", group: "ctxmgr", fallback: "false", description: "上下文语义摘要影子生成"},
{key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "语义摘要替换确定性叙事(保留位,未接线)"},
{key: "PI_CHANGE_REPORT_NARRATIVE", label: "PI 变更报告叙事", group: "docrevision", fallback: "false", description: "变更报告 PI 叙事增强;默认仅确定性回退"},
}
// Features 返回功能开关快照;source 恒为 env,均需重启生效。
func (s *Service) Features() []Feature {
values := map[string]string{}
if s.featureValues != nil {
values = s.featureValues()
}
out := make([]Feature, 0, len(featureCatalog))
for _, spec := range featureCatalog {
value, ok := values[spec.key]
if !ok {
if env, set := os.LookupEnv(spec.key); set {
value = env
} else {
value = spec.fallback
}
}
out = append(out, Feature{
Key: spec.key,
Label: spec.label,
Group: spec.group,
Value: value,
Source: "env",
RequiresRestart: true,
Description: spec.description,
})
}
return out
}
+142
View File
@@ -0,0 +1,142 @@
// Package sysadmin 系统管理台(一期)薄聚合层:只通过小接口读取其他模块的
// 公开状态,业务包不得 import 本包。
package sysadmin
import (
"context"
"time"
"chat-agent-system/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
)
// StatusProvider 业务服务的数据库/schema 公开状态(*bizdev.Service 满足)。
type StatusProvider interface {
DatabaseReady() bool
SchemaVersion() int64
LiveSchemaVersion(ctx context.Context) int64
}
// KBProber 知识库在线探针(*kb.Client 满足)。
type KBProber interface {
Online() bool
}
// Options 装配根注入的依赖;全部为只读探针,聚合层不持有业务写路径。
type Options struct {
Pool *pgxpool.Pool
Store SettingsStore // 测试可直接注入;为空且 Pool 非空时使用 PG store
Status StatusProvider
KB KBProber
TaskStoreReady func(ctx context.Context) bool
FeatureValues func() map[string]string // 组合根解析后的功能开关生效值(key 为环境变量名)
}
// Service 系统管理台聚合服务。
type Service struct {
cfg *config.Config
store SettingsStore
status StatusProvider
kb KBProber
taskStoreReady func(ctx context.Context) bool
featureValues func() map[string]string
specs []SettingSpec
startedAt time.Time
}
// NewService 构建聚合服务;cfg 允许为 nil(测试)。
func NewService(cfg *config.Config, opts Options) *Service {
store := opts.Store
if store == nil && opts.Pool != nil {
store = newPGSettingsStore(opts.Pool)
}
return &Service{
cfg: cfg,
store: store,
status: opts.Status,
kb: opts.KB,
taskStoreReady: opts.TaskStoreReady,
featureValues: opts.FeatureValues,
specs: defaultSettingSpecs(),
startedAt: time.Now(),
}
}
// Overview 系统总览,字段对齐前端 camelCase 契约。
type Overview struct {
Status string `json:"status"`
Env string `json:"env"`
AgentBackend string `json:"agentBackend"`
DatabaseReady bool `json:"databaseReady"`
SchemaVersion int64 `json:"schemaVersion"`
TaskStoreReady bool `json:"taskStoreReady"`
KBOnline bool `json:"kbOnline"`
UptimeSeconds int64 `json:"uptimeSeconds"`
}
// Overview 聚合 /health 同源信号。
func (s *Service) Overview(ctx context.Context) Overview {
dbReady := s.status != nil && s.status.DatabaseReady()
schemaVersion := int64(0)
if s.status != nil {
if dbReady {
schemaVersion = s.status.LiveSchemaVersion(ctx)
} else {
schemaVersion = s.status.SchemaVersion()
}
}
taskStoreReady := false
if s.taskStoreReady != nil {
taskStoreReady = s.taskStoreReady(ctx)
}
status := "ok"
if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory && !dbReady {
status = "degraded"
}
out := Overview{
Status: status,
DatabaseReady: dbReady,
SchemaVersion: schemaVersion,
TaskStoreReady: taskStoreReady,
KBOnline: s.kb != nil && s.kb.Online(),
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
}
if s.cfg != nil {
out.Env = s.cfg.Env
out.AgentBackend = s.cfg.AgentBackend
}
return out
}
// KBInfo 知识库连接视图;密码等敏感信息绝不输出。
type KBInfo struct {
Online bool `json:"online"`
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
User string `json:"user"`
EmbeddingModel string `json:"embeddingModel"`
RerankerEnabled bool `json:"rerankerEnabled"`
SSLMode string `json:"sslMode"`
}
// KBInfo 返回 KB 连接与模型配置(脱敏)。
func (s *Service) KBInfo() KBInfo {
out := KBInfo{
Online: s.kb != nil && s.kb.Online(),
// KbDSN 固定 sslmode=disable,如实暴露
SSLMode: "disable",
}
if s.cfg != nil {
out.Host = s.cfg.KbDbHost
out.Port = s.cfg.KbDbPort
out.Database = s.cfg.KbDbName
out.User = s.cfg.KbDbUser
out.EmbeddingModel = s.cfg.EmbeddingModel
out.RerankerEnabled = s.cfg.RerankerEndpointURL != ""
}
return out
}
+151
View File
@@ -0,0 +1,151 @@
package sysadmin
import (
"context"
"encoding/json"
"strings"
"testing"
"chat-agent-system/internal/config"
)
type fakeStatus struct {
ready bool
version int64
}
func (f fakeStatus) DatabaseReady() bool { return f.ready }
func (f fakeStatus) SchemaVersion() int64 {
return f.version
}
func (f fakeStatus) LiveSchemaVersion(_ context.Context) int64 { return f.version + 1 }
type fakeKB struct{ online bool }
func (f fakeKB) Online() bool { return f.online }
func testConfig() *config.Config {
return &config.Config{
Env: "test",
AgentBackend: "stub",
AllowInMemory: true,
KbDbHost: "kb.internal",
KbDbPort: 5432,
KbDbUser: "regkb",
KbDbPassword: "super-secret",
KbDbName: "regulations",
}
}
func TestOverview_AggregatesSources(t *testing.T) {
svc := NewService(testConfig(), Options{
Status: fakeStatus{ready: true, version: 48},
KB: fakeKB{online: true},
TaskStoreReady: func(context.Context) bool {
return true
},
})
ov := svc.Overview(context.Background())
if ov.Status != "ok" {
t.Fatalf("expected ok, got %s", ov.Status)
}
if ov.Env != "test" || ov.AgentBackend != "stub" {
t.Fatalf("unexpected env/backend: %+v", ov)
}
if !ov.DatabaseReady || !ov.TaskStoreReady || !ov.KBOnline {
t.Fatalf("expected all ready: %+v", ov)
}
// dbReady 时使用 LiveSchemaVersion(fake 返回 version+1)
if ov.SchemaVersion != 49 {
t.Fatalf("expected live schema version 49, got %d", ov.SchemaVersion)
}
if ov.UptimeSeconds < 0 {
t.Fatalf("uptime must be >= 0, got %d", ov.UptimeSeconds)
}
}
func TestOverview_DegradedWhenDBDownAndNotInMemory(t *testing.T) {
cfg := testConfig()
cfg.AllowInMemory = false
cfg.ContextDbHost = "127.0.0.1"
svc := NewService(cfg, Options{Status: fakeStatus{ready: false, version: 48}})
ov := svc.Overview(context.Background())
if ov.Status != "degraded" {
t.Fatalf("expected degraded, got %s", ov.Status)
}
// 未就绪时回退启动缓存版本
if ov.SchemaVersion != 48 {
t.Fatalf("expected cached schema version 48, got %d", ov.SchemaVersion)
}
if ov.TaskStoreReady || ov.KBOnline {
t.Fatalf("expected nil probes to report not-ready: %+v", ov)
}
}
func TestKBInfo_NeverLeaksSecrets(t *testing.T) {
svc := NewService(testConfig(), Options{KB: fakeKB{online: true}})
info := svc.KBInfo()
if !info.Online {
t.Fatal("expected online")
}
if info.Host != "kb.internal" || info.Port != 5432 || info.Database != "regulations" || info.User != "regkb" {
t.Fatalf("unexpected connection view: %+v", info)
}
if info.SSLMode != "disable" {
t.Fatalf("expected sslMode=disable (KbDSN fixed), got %s", info.SSLMode)
}
if info.RerankerEnabled {
t.Fatal("reranker endpoint empty must report disabled")
}
// 结构体无密码字段;序列化结果也不得出现密码值
raw, err := json.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if s := string(raw); strings.Contains(s, "super-secret") || strings.Contains(s, "assword") {
t.Fatalf("KBInfo leaks secret material: %s", s)
}
}
func TestFeatures_CoversCatalogAndInjectedValues(t *testing.T) {
svc := NewService(testConfig(), Options{
FeatureValues: func() map[string]string {
return map[string]string{
"AGENT_BACKEND": "stub",
"FAV_PUBLISHER_ENABLED": "true",
"MULTI_INTENT_PLAN_TIER": "shadow",
}
},
})
features := svc.Features()
if len(features) != len(featureCatalog) {
t.Fatalf("expected %d features, got %d", len(featureCatalog), len(features))
}
byKey := map[string]Feature{}
for _, f := range features {
byKey[f.Key] = f
if f.Source != "env" || !f.RequiresRestart {
t.Fatalf("feature %s must be env-sourced and restart-bound: %+v", f.Key, f)
}
if f.Label == "" || f.Group == "" {
t.Fatalf("feature %s missing label/group: %+v", f.Key, f)
}
}
if byKey["FAV_PUBLISHER_ENABLED"].Value != "true" {
t.Fatalf("expected injected FAV value, got %+v", byKey["FAV_PUBLISHER_ENABLED"])
}
if byKey["FAV_PUBLISHER_ENABLED"].Group != "artifact-vault" {
t.Fatalf("unexpected group: %+v", byKey["FAV_PUBLISHER_ENABLED"])
}
if byKey["MULTI_INTENT_PLAN_TIER"].Value != "shadow" {
t.Fatalf("expected injected plan tier, got %+v", byKey["MULTI_INTENT_PLAN_TIER"])
}
if byKey["INTENT_V2_SHADOW"].Value != "false" {
t.Fatalf("expected catalog fallback false, got %+v", byKey["INTENT_V2_SHADOW"])
}
for _, key := range []string{"AGENT_BACKEND", "AUTH_REGISTRATION_ENABLED", "FAV_DEGRADED_READ", "SEMANTIC_SUMMARY_SHADOW", "PI_CHANGE_REPORT_NARRATIVE"} {
if _, ok := byKey[key]; !ok {
t.Fatalf("catalog must cover %s", key)
}
}
}
+198
View File
@@ -0,0 +1,198 @@
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
}
}
+210
View File
@@ -0,0 +1,210 @@
package sysadmin
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
)
// memStore 进程内 SettingsStore,用于读优先级与写路径测试。
type memStore struct {
mu sync.Mutex
items map[string]memEntry
}
type memEntry struct {
value json.RawMessage
updatedBy *int64
updatedAt time.Time
}
func newMemStore() *memStore {
return &memStore{items: map[string]memEntry{}}
}
func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, error) {
m.mu.Lock()
defer m.mu.Unlock()
entry, ok := m.items[key]
if !ok {
return nil, nil, nil, ErrSettingNotFound
}
at := entry.updatedAt
return entry.value, entry.updatedBy, &at, nil
}
func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) {
m.mu.Lock()
defer m.mu.Unlock()
at := time.Now()
m.items[key] = memEntry{value: append(json.RawMessage(nil), value...), updatedBy: &updatedBy, updatedAt: at}
return at, nil
}
func findSetting(t *testing.T, settings []Setting, key string) Setting {
t.Helper()
for _, s := range settings {
if s.Key == key {
return s
}
}
t.Fatalf("setting %s not found", key)
return Setting{}
}
func TestListSettings_FallsBackToDefaultWithoutStore(t *testing.T) {
svc := NewService(nil, Options{})
settings, err := svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
if len(settings) != len(defaultSettingSpecs()) {
t.Fatalf("expected %d settings, got %d", len(defaultSettingSpecs()), len(settings))
}
notice := findSetting(t, settings, "ui.maintenance_notice")
if notice.Value != "" || notice.Default != "" {
t.Fatalf("expected empty default notice, got value=%v default=%v", notice.Value, notice.Default)
}
if notice.UpdatedAt != nil || notice.UpdatedBy != nil {
t.Fatalf("default-resolved setting must not carry update metadata: %+v", notice)
}
reminder := findSetting(t, settings, "notifications.stage_advance_reminder")
if reminder.Value != true {
t.Fatalf("expected bool default true, got %v", reminder.Value)
}
}
func TestListSettings_DBOverridesDefault(t *testing.T) {
store := newMemStore()
if _, err := store.Upsert(context.Background(), "ui.maintenance_notice", json.RawMessage(`"停机维护中"`), 7); err != nil {
t.Fatalf("seed store: %v", err)
}
svc := NewService(nil, Options{Store: store})
settings, err := svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
notice := findSetting(t, settings, "ui.maintenance_notice")
if notice.Value != "停机维护中" {
t.Fatalf("expected DB value, got %v", notice.Value)
}
if notice.UpdatedBy == nil || *notice.UpdatedBy != 7 {
t.Fatalf("expected updatedBy=7, got %+v", notice.UpdatedBy)
}
if notice.UpdatedAt == nil {
t.Fatal("expected updatedAt from DB row")
}
// 未写入的 key 仍回退 default
limit := findSetting(t, settings, "kb.default_search_limit")
if limit.Value != 8.0 {
t.Fatalf("expected default 8, got %v", limit.Value)
}
}
func TestListSettings_EnvLayerBetweenDBAndDefault(t *testing.T) {
t.Setenv("SYSADMIN_TEST_FLAG", "true")
specs := []SettingSpec{
{Key: "test.env_flag", Label: "env 中间层", Group: "test", Type: SettingTypeBool, Default: false, Writable: true, EnvVar: "SYSADMIN_TEST_FLAG"},
}
svc := &Service{specs: specs}
settings, err := svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
if settings[0].Value != true {
t.Fatalf("expected env override true, got %v", settings[0].Value)
}
// DB 优先于 env
store := newMemStore()
if _, err := store.Upsert(context.Background(), "test.env_flag", json.RawMessage(`false`), 1); err != nil {
t.Fatalf("seed store: %v", err)
}
svc = &Service{specs: specs, store: store}
settings, err = svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
if settings[0].Value != false {
t.Fatalf("expected DB to win over env, got %v", settings[0].Value)
}
}
func TestUpdateSetting_UnknownKey(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
_, err := svc.UpdateSetting(context.Background(), "no.such.key", json.RawMessage(`"x"`), 1)
if !errors.Is(err, ErrUnknownSetting) {
t.Fatalf("expected ErrUnknownSetting, got %v", err)
}
}
func TestUpdateSetting_ReadOnlyRejected(t *testing.T) {
specs := []SettingSpec{
{Key: "test.locked", Label: "只读项", Group: "test", Type: SettingTypeString, Default: "a", Writable: false},
}
svc := &Service{specs: specs, store: newMemStore()}
_, err := svc.UpdateSetting(context.Background(), "test.locked", json.RawMessage(`"b"`), 1)
if !errors.Is(err, ErrSettingReadOnly) {
t.Fatalf("expected ErrSettingReadOnly, got %v", err)
}
}
func TestUpdateSetting_TypeValidation(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
cases := []struct {
key string
raw string
}{
{"ui.maintenance_notice", `123`}, // string 声明收到 number
{"ui.read_only_mode", `"true"`}, // bool 声明收到 string
{"kb.default_search_limit", `"8"`}, // number 声明收到 string
{"ui.maintenance_notice", ``}, // 空 body value
{"ui.maintenance_notice", `{"a":1}`}, // string 声明收到 object
}
for _, tc := range cases {
_, err := svc.UpdateSetting(context.Background(), tc.key, json.RawMessage(tc.raw), 1)
if !errors.Is(err, ErrInvalidSettingValue) {
t.Fatalf("%s <- %s: expected ErrInvalidSettingValue, got %v", tc.key, tc.raw, err)
}
}
}
func TestUpdateSetting_WithoutStore(t *testing.T) {
svc := NewService(nil, Options{})
_, err := svc.UpdateSetting(context.Background(), "ui.maintenance_notice", json.RawMessage(`"x"`), 1)
if !errors.Is(err, ErrStoreUnavailable) {
t.Fatalf("expected ErrStoreUnavailable, got %v", err)
}
}
func TestUpdateSetting_PersistsAndReturnsUpdatedView(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
setting, err := svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`12`), 42)
if err != nil {
t.Fatalf("UpdateSetting: %v", err)
}
if setting.Value != 12.0 {
t.Fatalf("expected 12, got %v", setting.Value)
}
if setting.Default != 8.0 {
t.Fatalf("default must stay 8, got %v", setting.Default)
}
if setting.UpdatedBy == nil || *setting.UpdatedBy != 42 {
t.Fatalf("expected updatedBy=42, got %+v", setting.UpdatedBy)
}
if setting.UpdatedAt == nil {
t.Fatal("expected updatedAt after write")
}
// 覆盖写:值更新、审计字段跟随
setting, err = svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`20`), 43)
if err != nil {
t.Fatalf("second UpdateSetting: %v", err)
}
if setting.Value != 20.0 || setting.UpdatedBy == nil || *setting.UpdatedBy != 43 {
t.Fatalf("expected overwrite (20, by 43), got (%v, %+v)", setting.Value, setting.UpdatedBy)
}
}
+56
View File
@@ -0,0 +1,56 @@
package sysadmin
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrSettingNotFound DB 中无该 key 的行(回退 env/default)。
var ErrSettingNotFound = errors.New("sysadmin: setting not found")
// SettingsStore system_settings 表读写(表 owner 为 sysadmin)。
type SettingsStore interface {
Get(ctx context.Context, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, err error)
Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error)
}
type pgSettingsStore struct {
pool *pgxpool.Pool
}
func newPGSettingsStore(pool *pgxpool.Pool) SettingsStore {
return &pgSettingsStore{pool: pool}
}
func (s *pgSettingsStore) Get(ctx context.Context, key string) (json.RawMessage, *int64, *time.Time, error) {
var raw []byte
var updatedBy *int64
var updatedAt time.Time
err := s.pool.QueryRow(ctx, `
SELECT value, updated_by, updated_at FROM system_settings WHERE key = $1
`, key).Scan(&raw, &updatedBy, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil, ErrSettingNotFound
}
return nil, nil, nil, err
}
return json.RawMessage(raw), updatedBy, &updatedAt, nil
}
func (s *pgSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) {
var updatedAt time.Time
err := s.pool.QueryRow(ctx, `
INSERT INTO system_settings (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = now()
RETURNING updated_at
`, key, []byte(value), updatedBy).Scan(&updatedAt)
return updatedAt, err
}
+57
View File
@@ -0,0 +1,57 @@
package sysadmin
import (
"context"
"encoding/json"
"testing"
"chat-agent-system/internal/db"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestPGSettingsStore_RoundTrip(t *testing.T) {
dsn := db.SkipIfNoTestDatabaseURL(t)
db.RefuseSharedEnvDSN(t, dsn)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
store := newPGSettingsStore(pool)
key := "ui.maintenance_notice"
if _, _, _, err := store.Get(ctx, key); err != ErrSettingNotFound {
t.Fatalf("expected ErrSettingNotFound, got %v", err)
}
if _, err := store.Upsert(ctx, key, json.RawMessage(`"维护中"`), 1); err != nil {
t.Fatalf("upsert: %v", err)
}
raw, updatedBy, updatedAt, err := store.Get(ctx, key)
if err != nil {
t.Fatalf("get: %v", err)
}
if string(raw) != `"维护中"` {
t.Fatalf("unexpected value: %s", raw)
}
if updatedBy == nil || *updatedBy != 1 {
t.Fatalf("expected updatedBy=1, got %+v", updatedBy)
}
if updatedAt == nil {
t.Fatal("expected updatedAt")
}
if _, err := store.Upsert(ctx, key, json.RawMessage(`"恢复"`), 2); err != nil {
t.Fatalf("second upsert: %v", err)
}
raw, updatedBy, _, err = store.Get(ctx, key)
if err != nil {
t.Fatalf("get after overwrite: %v", err)
}
if string(raw) != `"恢复"` || updatedBy == nil || *updatedBy != 2 {
t.Fatalf("expected overwrite, got %s by %+v", raw, updatedBy)
}
}