diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 440ae23e..9fba27c8 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -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: diff --git a/deploy/postgres/goose/00049_2026-09-17-system-settings.sql b/deploy/postgres/goose/00049_2026-09-17-system-settings.sql new file mode 100644 index 00000000..8e078e3f --- /dev/null +++ b/deploy/postgres/goose/00049_2026-09-17-system-settings.sql @@ -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; diff --git a/internal/app/app.go b/internal/app/app.go index 06b33410..16ba6ba3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/server/admin_system_handlers.go b/internal/server/admin_system_handlers.go new file mode 100644 index 00000000..2d7327f3 --- /dev/null +++ b/internal/server/admin_system_handlers.go @@ -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) + } +} diff --git a/internal/server/admin_system_handlers_test.go b/internal/server/admin_system_handlers_test.go new file mode 100644 index 00000000..b0f0731c --- /dev/null +++ b/internal/server/admin_system_handlers_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 480a74ca..05cc2722 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go new file mode 100644 index 00000000..cd541ddd --- /dev/null +++ b/internal/sysadmin/features.go @@ -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 +} diff --git a/internal/sysadmin/service.go b/internal/sysadmin/service.go new file mode 100644 index 00000000..da636643 --- /dev/null +++ b/internal/sysadmin/service.go @@ -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 +} diff --git a/internal/sysadmin/service_test.go b/internal/sysadmin/service_test.go new file mode 100644 index 00000000..7827cf17 --- /dev/null +++ b/internal/sysadmin/service_test.go @@ -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) + } + } +} diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go new file mode 100644 index 00000000..cd22e093 --- /dev/null +++ b/internal/sysadmin/settings.go @@ -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 + } +} diff --git a/internal/sysadmin/settings_test.go b/internal/sysadmin/settings_test.go new file mode 100644 index 00000000..e26a6a95 --- /dev/null +++ b/internal/sysadmin/settings_test.go @@ -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) + } +} diff --git a/internal/sysadmin/store.go b/internal/sysadmin/store.go new file mode 100644 index 00000000..9d30f6ce --- /dev/null +++ b/internal/sysadmin/store.go @@ -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 +} diff --git a/internal/sysadmin/store_pg_test.go b/internal/sysadmin/store_pg_test.go new file mode 100644 index 00000000..e4c02df5 --- /dev/null +++ b/internal/sysadmin/store_pg_test.go @@ -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) + } +}