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:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user