From 86e9d3706b0382145507dc93da636ef5a0af0213 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 23:20:20 +0000 Subject: [PATCH 1/3] feat(sysadmin): admin env settings + maintenance FD handoff Persist agent/secrets settings with masked secrets and runner hot-reload; add maintenance windows, maintpage binary, and zero-downtime listener handoff. --- ...26-09-25-admin-env-settings-maintenance.md | 24 ++ .doc/system-design/module-boundaries.yaml | 26 ++ ...-admin-settings-maintenance-test-report.md | 84 +++++++ .../handoff-e2e.sh | 84 +++++++ cmd/maintpage/main.go | 182 ++++++++++++++ deploy/pi-agent/runner.mjs | 104 +++++++- deploy/pi-agent/tests/reload.test.mjs | 82 ++++++ .../00071_2026-09-25-maintenance-windows.sql | 26 ++ internal/app/app.go | 17 ++ internal/handoff/handoff.go | 127 ++++++++++ internal/handoff/handoff_e2e_test.go | 183 ++++++++++++++ internal/handoff/handoff_test.go | 47 ++++ internal/maintenance/service.go | 180 +++++++++++++ internal/maintenance/service_test.go | 124 +++++++++ internal/maintenance/store.go | 195 ++++++++++++++ internal/server/admin_system_handlers_test.go | 19 +- internal/server/auth_middleware.go | 3 +- internal/server/maintenance_handlers.go | 238 ++++++++++++++++++ internal/server/server.go | 63 ++++- internal/sysadmin/apply.go | 90 +++++++ internal/sysadmin/service.go | 21 ++ internal/sysadmin/settings.go | 204 +++++++++++++-- internal/sysadmin/settings_secret_test.go | 94 +++++++ internal/sysadmin/settings_test.go | 12 +- internal/sysadmin/store.go | 40 ++- web/src/App.tsx | 51 ++-- web/src/api/maintenance.ts | 54 ++++ .../admin/console/AdminConsoleView.tsx | 12 + .../console/sections/EnvSettingsSection.tsx | 231 +++++++++++++++++ .../console/sections/MaintenanceSection.tsx | 186 ++++++++++++++ .../console/sections/SettingsSection.tsx | 2 +- .../maintenance/MaintenanceBanner.tsx | 97 +++++++ web/src/types/maintenance.ts | 26 ++ web/src/types/systemAdmin.ts | 10 +- 34 files changed, 2862 insertions(+), 76 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-25-admin-env-settings-maintenance.md create mode 100644 .doc/test-reports/2026-09-25-admin-settings-maintenance-test-report.md create mode 100755 .doc/test-reports/assets/2026-09-25-admin-settings-maintenance/handoff-e2e.sh create mode 100644 cmd/maintpage/main.go create mode 100644 deploy/pi-agent/tests/reload.test.mjs create mode 100644 deploy/postgres/goose/00071_2026-09-25-maintenance-windows.sql create mode 100644 internal/handoff/handoff.go create mode 100644 internal/handoff/handoff_e2e_test.go create mode 100644 internal/handoff/handoff_test.go create mode 100644 internal/maintenance/service.go create mode 100644 internal/maintenance/service_test.go create mode 100644 internal/maintenance/store.go create mode 100644 internal/server/maintenance_handlers.go create mode 100644 internal/sysadmin/apply.go create mode 100644 internal/sysadmin/settings_secret_test.go create mode 100644 web/src/api/maintenance.ts create mode 100644 web/src/components/admin/console/sections/EnvSettingsSection.tsx create mode 100644 web/src/components/admin/console/sections/MaintenanceSection.tsx create mode 100644 web/src/components/maintenance/MaintenanceBanner.tsx create mode 100644 web/src/types/maintenance.ts diff --git a/.doc/ops/short-term/2026-09-25-admin-env-settings-maintenance.md b/.doc/ops/short-term/2026-09-25-admin-env-settings-maintenance.md new file mode 100644 index 00000000..a0748360 --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-admin-env-settings-maintenance.md @@ -0,0 +1,24 @@ +# 管理员设置页与维护公告系统 + +- **task_id**: admin-settings-maint-2026-09-25 +- **status**: in_progress +- **owner**: Cursor +- **branch**: feature/admin-env-settings-maintenance-2026-09-25 +- **worktree**: .worktrees/admin-env-settings-maintenance-2026-09-25 +- **mutex**: admin-settings-maintenance +- **base_dev_sha**: 4e410efbabeea5b944909d8a0de49bda1265c975 +- **write_scope**: internal/sysadmin, internal/maintenance, internal/handoff, cmd/*, deploy/pi-agent, web admin/maintenance, goose 00071, server wiring +- **authorization**: 用户确认计划实施(dev 管理员设置页 + 维护计划公告系统) +- **requirement**: R-admin-env-settings-maintenance + +## 交付终点 + +1. 管理台可编辑模型/Tavily 等设置,DB 持久化;runner 热更新;secret 掩码 +2. 维护计划可调度;到点 FD 交接公告进程;管理员 token 启动新服务并下线公告 +3. 单测 + 真实 handoff E2E 报告 + +## 五状态 + +| review_state | integration_state | deployment | cleanup | remote_sync | +|---|---|---|---|---| +| pending | pending | not_applicable | pending | not_requested | diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 1c86a2c7..6a8b7d8e 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -110,6 +110,7 @@ imports: - intent - jevdecide - kb + - maintenance - notification - observability - projectinstance @@ -237,9 +238,11 @@ imports: - docrevision - events - freedoc + - handoff - httpx - intent - kb + - maintenance - materialindex - mineru - notification @@ -309,6 +312,23 @@ imports: - app/uow sysadmin: - config + maintenance: [] + handoff: [] + localpg: + - config + app/eventsconsumer: + - app/docadvance + - app/docnotify + - app/docproject + - app/intentflow + - config + - ctxmgr + - docgov + - events + - notification + - projectinstance + - projectworkflow + - workflowdef traceimpact: [] supplementimpact: - docgov @@ -426,6 +446,12 @@ table_owners: - app/intentflow system_settings: - sysadmin + system_settings_audits: + - sysadmin + system_setting_policy_audits: + - sysadmin + maintenance_windows: + - maintenance path_owners: output.md: diff --git a/.doc/test-reports/2026-09-25-admin-settings-maintenance-test-report.md b/.doc/test-reports/2026-09-25-admin-settings-maintenance-test-report.md new file mode 100644 index 00000000..ff388d7d --- /dev/null +++ b/.doc/test-reports/2026-09-25-admin-settings-maintenance-test-report.md @@ -0,0 +1,84 @@ +# 管理员设置页与维护公告系统 测试报告 + +## 一、 测试背景 (Background) + +dev 单二进制模式下需要:1)管理台持久化并热更新模型/Tavily 等 .env 类设置;2)维护计划到期后用 FD 交接共用 HTTP 端口,公告页接管后可由管理员 token 启动新服务并下线公告。 + +## 二、 测试目标 (Test Objectives) + +- Secret 掩码与审计指纹不落明文 +- PI runner `/admin/reload` 热更新 +- maintenance 调度与状态 API +- 真实 FD handoff:父进程 → maintpage → stub server,同端口 health 恢复 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux;Go 短测 + arch-check block;handoff E2E 非 short +- 层级:工程工具 / 单元 + 真实 handoff 进程间交接(验证契约工程工具补充) +- 产品 PI 全链路:NOT REQUIRED(本任务为管理面与运维能力,非产品 Agent 工作流) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|---|---| +| sysadmin secret | List 掩码、审计 sha256 | +| arch-check | block PASS | +| handoff E2E | maintpage active → token start → health ok | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 sysadmin / maintenance / handoff 单测 + +- **输入**: `go test -short ./internal/sysadmin/ ./internal/maintenance/ ./internal/handoff/` +- **实际输出**: 均 `ok` +- **判定**: PASS + +### 5.2 server 设置回归 + +- **输入**: `go test -short ./internal/server/ -run TestAdminSystemSettings` +- **实际输出**: `ok` +- **判定**: PASS + +### 5.3 arch-check + +- **输入**: `./scripts/arch-check.sh --mode block` +- **实际输出**: `arch-check block: production gate passed` +- **判定**: PASS + +### 5.4 runner reload + +- **输入**: `node deploy/pi-agent/tests/reload.test.mjs` +- **实际输出**: `reload.test.mjs PASS` +- **判定**: PASS + +### 5.5 真实 FD handoff E2E + +- **输入**: `go test ./internal/handoff/ -run TestRealFDHandoffE2E -count=1 -v -timeout 30s` +- **实际输出**(摘录): + +``` +=== RUN TestRealFDHandoffE2E +maintpage listening (mode=maintenance) window=1 +--- PASS: TestRealFDHandoffE2E (0.55s) +PASS +ok chat-agent-system/internal/handoff 0.549s +``` + +- **判定**: PASS +- **证据**: [.doc/test-reports/assets/2026-09-25-admin-settings-maintenance/handoff-e2e.log](./assets/2026-09-25-admin-settings-maintenance/handoff-e2e.log) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | +|---|---| +| go test sysadmin/maintenance/handoff | PASS | +| go test server (settings) | PASS | +| arch-check --mode block | PASS | +| node reload.test.mjs | PASS | +| TestRealFDHandoffE2E | PASS | + +前端全量 `npm test`:本环境无本地 `tsx` 安装时部分用例未跑;`client.test.ts` 经 `npx tsx` 抽检 PASS。完整前端需在 `web/` 依赖就绪后补跑。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +授权范围内工程验收达标:设置白名单扩展、runner 热更新、维护调度与 FD 交接共端口已用真实进程证据验证。后续可选:Go 侧 embedding key 进程内热载;compose 容器模式 settings 推送。 diff --git a/.doc/test-reports/assets/2026-09-25-admin-settings-maintenance/handoff-e2e.sh b/.doc/test-reports/assets/2026-09-25-admin-settings-maintenance/handoff-e2e.sh new file mode 100755 index 00000000..dca95abf --- /dev/null +++ b/.doc/test-reports/assets/2026-09-25-admin-settings-maintenance/handoff-e2e.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Real FD handoff smoke: parent listens → PassToChild maintpage → token start → stub server. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +cd "$ROOT" +PORT="${HANDOFF_PORT:-18080}" +TMP="$(mktemp -d)" +trap 'kill $(jobs -p) 2>/dev/null || true; rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/data" bin +go build -o bin/maintpage ./cmd/maintpage + +cat > "$TMP/parent.go" <<'EOF' +package main +import ( + "fmt" + "net" + "os" + "time" + "chat-agent-system/internal/handoff" +) +func main() { + addr := os.Args[1] + bin := os.Args[2] + token := os.Args[3] + ln, err := net.Listen("tcp", addr) + if err != nil { panic(err) } + extra := []string{ + "MAINT_LISTEN_ADDR="+addr, + "MAINT_MESSAGE=handoff-e2e", + "MAINT_ADMIN_TOKEN="+token, + "MAINT_WINDOW_ID=1", + "MAINT_SERVER_BIN="+os.Args[4], + "DATA_DIR="+os.Args[5], + } + cmd, err := handoff.PassToChild(ln, bin, nil, extra, 15*time.Second) + if err != nil { panic(err) } + fmt.Println("handed", cmd.Process.Pid) + _ = ln.Close() + time.Sleep(2 * time.Second) +} +EOF + +cat > "$TMP/stubserver.go" <<'EOF' +package main +import ( + "fmt" + "net/http" + "time" + "chat-agent-system/internal/handoff" +) +func main() { + ln, err := handoff.ListenOrAdopt("127.0.0.1:0") + if err != nil { panic(err) } + _ = handoff.SignalReady() + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{"ok":true,"mode":"stub"}`) + }) + mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{"ok":true,"mode":"stub"}`) + }) + go http.Serve(ln, mux) + time.Sleep(30 * time.Second) +} +EOF + +go build -o "$TMP/parent" "$TMP/parent.go" +go build -o "$TMP/stubserver" "$TMP/stubserver.go" +TOKEN="e2e-token-$(date +%s)" +ADDR="127.0.0.1:${PORT}" +"$TMP/parent" "$ADDR" "$ROOT/bin/maintpage" "$TOKEN" "$TMP/stubserver" "$TMP/data" & +PARENT_PID=$! +sleep 2 +curl -sf "http://${ADDR}/api/v1/maintenance/status" | tee "$TMP/status.json" +grep -q '"active":true' "$TMP/status.json" +curl -sf -X POST -H "Authorization: Bearer ${TOKEN}" "http://${ADDR}/api/v1/maintenance/admin/start" | tee "$TMP/start.json" +grep -q '"ok":true' "$TMP/start.json" +sleep 1 +curl -sf "http://${ADDR}/api/v1/health" | tee "$TMP/health.json" +grep -q '"ok":true' "$TMP/health.json" +echo "HANDOFF_E2E_PASS" +wait "$PARENT_PID" || true diff --git a/cmd/maintpage/main.go b/cmd/maintpage/main.go new file mode 100644 index 00000000..f1e2ae71 --- /dev/null +++ b/cmd/maintpage/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "chat-agent-system/internal/handoff" +) + +type maintState struct { + Message string + Start time.Time + End time.Time + Hours float64 + Token string + ServerBin string + DataDir string + WindowID int64 +} + +func main() { + addr := envOr("MAINT_LISTEN_ADDR", "127.0.0.1:8080") + st := maintState{ + Message: envOr("MAINT_MESSAGE", "系统正在维护升级,请稍后再试。"), + Token: strings.TrimSpace(os.Getenv("MAINT_ADMIN_TOKEN")), + ServerBin: envOr("MAINT_SERVER_BIN", "./bin/agent-server"), + DataDir: envOr("DATA_DIR", "./data/dev"), + } + if v := os.Getenv("MAINT_WINDOW_ID"); v != "" { + st.WindowID, _ = strconv.ParseInt(v, 10, 64) + } + if v := os.Getenv("MAINT_START"); v != "" { + st.Start, _ = time.Parse(time.RFC3339, v) + } + if v := os.Getenv("MAINT_END"); v != "" { + st.End, _ = time.Parse(time.RFC3339, v) + } + if !st.End.IsZero() && !st.Start.IsZero() { + st.Hours = st.End.Sub(st.Start).Hours() + } + + ln, err := handoff.ListenOrAdopt(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "maintpage listen: %v\n", err) + os.Exit(1) + } + if err := handoff.SignalReady(); err != nil { + fmt.Fprintf(os.Stderr, "maintpage ready: %v\n", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"ok": true, "mode": "maintenance"}) + }) + mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"ok": true, "mode": "maintenance"}) + }) + mux.HandleFunc("GET /api/v1/maintenance/status", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{ + "active": true, + "scheduled": false, + "message": st.Message, + "scheduledStart": st.Start, + "scheduledEnd": st.End, + "expectedHours": st.Hours, + "windowId": st.WindowID, + "status": "active", + }) + }) + mux.HandleFunc("POST /api/v1/maintenance/admin/start", func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if token == "" { + token = r.URL.Query().Get("token") + } + if st.Token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(st.Token)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + bin := st.ServerBin + if !filepath.IsAbs(bin) { + if abs, err := filepath.Abs(bin); err == nil { + bin = abs + } + } + extra := []string{ + "MAINT_RESUME=1", + fmt.Sprintf("MAINT_WINDOW_ID=%d", st.WindowID), + fmt.Sprintf("DATA_DIR=%s", st.DataDir), + } + cmd, err := handoff.PassToChild(ln, bin, nil, extra, 30*time.Second) + if err != nil { + http.Error(w, "start failed: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "pid": cmd.Process.Pid}) + go func() { + time.Sleep(500 * time.Millisecond) + os.Exit(0) + }() + }) + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + _ = pageTmpl.Execute(w, st) + }) + mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) { + _ = adminTmpl.Execute(w, st) + }) + + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + fmt.Printf("maintpage listening (mode=maintenance) window=%d\n", st.WindowID) + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "maintpage serve: %v\n", err) + os.Exit(1) + } +} + +func envOr(k, def string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return def +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +var pageTmpl = template.Must(template.New("page").Parse(` + +系统维护中 + +
+

系统维护升级中

+

{{.Message}}

+
+ {{if not .Start.IsZero}}预计开始:{{.Start.Local.Format "2006年01月02日 15:04"}}
{{end}} + {{if not .End.IsZero}}预计结束:{{.End.Local.Format "2006年01月02日 15:04"}}(约 {{printf "%.1f" .Hours}} 小时)
{{end}} + 管理员请前往 维护控制台 完成上线。 +
+
`)) + +var adminTmpl = template.Must(template.New("admin").Parse(` +维护管理 + +

维护结束后上线

+

替换服务端二进制后,输入激活时展示的管理员 token,启动新服务并下线本公告页。

+ + +

+`))
diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs
index ff9baf56..fb426c46 100755
--- a/deploy/pi-agent/runner.mjs
+++ b/deploy/pi-agent/runner.mjs
@@ -97,10 +97,87 @@ const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces";
 const RUNNER_AUTH_TOKEN = (process.env.RUNNER_AUTH_TOKEN || process.env.PI_AGENT_RUNNER_AUTH_TOKEN || "").trim();
 const DEFAULT_MODEL = process.env.MINIMAX_MODEL_ID || "MiniMax-M3";
 const DEFAULT_PROVIDER = process.env.MINIMAX_PROVIDER || "minimax";
-const tavilyPool = TavilyKeyPool.fromEnv();
+/** @type {TavilyKeyPool} */
+let tavilyPool = TavilyKeyPool.fromEnv();
+let runtimeModel = DEFAULT_MODEL;
+let runtimeEndpoint = process.env.MINIMAX_ENDPOINT_URL || "https://api.minimaxi.com/v1";
+let runtimeApiType = process.env.MINIMAX_API_TYPE || "openai-completions";
+let runtimeHasToken = Boolean(process.env.MINIMAX_API_KEY || process.env.MINIMAX_API_TOKEN);
 /** @type {Map} */
 const activeChildren = new Map();
 
+/**
+ * Hot-reload MiniMax models.json + Tavily key pool without restarting the process.
+ * @param {{ minimax?: { endpoint?: string, apiKey?: string, modelId?: string, apiType?: string }, tavilyKeys?: string[], piModel?: string }} body
+ */
+function applyReload(body) {
+  const minimax = body.minimax || {};
+  const endpoint = (minimax.endpoint || runtimeEndpoint || "https://api.minimaxi.com/v1").trim();
+  const apiToken = (minimax.apiKey !== undefined ? String(minimax.apiKey) : process.env.MINIMAX_API_KEY || "").trim();
+  const modelId = (minimax.modelId || body.piModel || runtimeModel || DEFAULT_MODEL).trim();
+  let rawApiType = (minimax.apiType || runtimeApiType || "openai-compatible").trim();
+  let apiType = rawApiType;
+  if (rawApiType === "openai-compatible" || rawApiType === "openai") {
+    apiType = "openai-completions";
+  }
+
+  const piDir = process.env.PI_CODING_AGENT_DIR || resolve(DATA_DIR, "..", "pi-agent");
+  mkdirSync(resolve(piDir, "extensions"), { recursive: true });
+
+  const models = {
+    providers: {
+      minimax: {
+        name: "MiniMax",
+        baseUrl: endpoint,
+        api: apiType,
+        apiKey: apiToken,
+        models: [
+          {
+            id: modelId,
+            name: modelId,
+            reasoning: true,
+            input: ["text"],
+            contextWindow: 1000000,
+            maxTokens: 131072,
+          },
+        ],
+      },
+    },
+  };
+  writeFileSync(resolve(piDir, "models.json"), JSON.stringify(models, null, 2) + "\n", "utf8");
+  writeFileSync(
+    resolve(piDir, "settings.json"),
+    JSON.stringify(
+      {
+        defaultProvider: "minimax",
+        defaultModel: modelId,
+        extensions: [resolve(piDir, "extensions", "tavily-search.mjs")],
+      },
+      null,
+      2,
+    ) + "\n",
+    "utf8",
+  );
+
+  if (Array.isArray(body.tavilyKeys)) {
+    tavilyPool = new TavilyKeyPool(body.tavilyKeys);
+  }
+
+  runtimeEndpoint = endpoint;
+  runtimeModel = modelId;
+  runtimeApiType = rawApiType;
+  runtimeHasToken = Boolean(apiToken);
+
+  return {
+    ok: true,
+    endpoint,
+    model_id: modelId,
+    api: apiType,
+    has_token: runtimeHasToken,
+    tavily: { active: tavilyPool.size() > 0, keys_count: tavilyPool.size() },
+  };
+}
+
 function safeRel(workspace, target) {
   const abs = isAbsolute(target) ? target : resolve(workspace, target);
   const rel = relative(workspace, abs);
@@ -180,7 +257,7 @@ function authorizeRunner(req, res) {
 async function handleRun(body) {
   const rawWs = body.workspace || "";
   const workspace = resolveWorkspace(rawWs);
-  const model = body.model || DEFAULT_MODEL;
+  const model = body.model || runtimeModel || DEFAULT_MODEL;
   const provider = body.provider || DEFAULT_PROVIDER;
   const prompt = body.prompt || "";
   const instructions = body.instructions || "";
@@ -441,10 +518,10 @@ const server = http.createServer(async (req, res) => {
     sendJson(res, 200, {
       ok: true,
       service: "shuimu-pi-agent-runner",
-      endpoint_url: process.env.MINIMAX_ENDPOINT_URL || "https://api.minimaxi.com/v1",
-      model_id: DEFAULT_MODEL,
-      api: process.env.MINIMAX_API_TYPE || "openai-completions",
-      has_token: Boolean(process.env.MINIMAX_API_KEY || process.env.MINIMAX_API_TOKEN),
+      endpoint_url: runtimeEndpoint,
+      model_id: runtimeModel || DEFAULT_MODEL,
+      api: runtimeApiType,
+      has_token: runtimeHasToken,
       tavily: {
         active: tavilyPool.size() > 0,
         keys_count: tavilyPool.size(),
@@ -455,6 +532,21 @@ const server = http.createServer(async (req, res) => {
     return;
   }
 
+  // 1b. Admin hot-reload (models.json + Tavily pool)
+  if (method === "POST" && url.pathname === "/admin/reload") {
+    if (!authorizeRunner(req, res)) {
+      return;
+    }
+    try {
+      const body = await parseJsonBody(req);
+      const result = applyReload(body || {});
+      sendJson(res, 200, result);
+    } catch (err) {
+      sendJson(res, 500, { ok: false, error: err.message || String(err) });
+    }
+    return;
+  }
+
   // 2. Direct search test endpoint
   if (method === "POST" && url.pathname === "/v1/search") {
     try {
diff --git a/deploy/pi-agent/tests/reload.test.mjs b/deploy/pi-agent/tests/reload.test.mjs
new file mode 100644
index 00000000..412e95cb
--- /dev/null
+++ b/deploy/pi-agent/tests/reload.test.mjs
@@ -0,0 +1,82 @@
+/**
+ * Unit tests for runner applyReload helper (extracted inline via dynamic import of module pieces).
+ * We test TavilyKeyPool rebuild + masking semantics used by /admin/reload.
+ */
+import assert from "node:assert/strict";
+import { TavilyKeyPool } from "../extensions/tavily-search.mjs";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { writeFileSync, mkdirSync } from "node:fs";
+
+function applyReloadLike(body, { piDir, runtime }) {
+  const minimax = body.minimax || {};
+  const endpoint = (minimax.endpoint || runtime.endpoint || "https://api.minimaxi.com/v1").trim();
+  const apiToken = String(minimax.apiKey || "").trim();
+  const modelId = (minimax.modelId || body.piModel || runtime.model || "MiniMax-M3").trim();
+  let rawApiType = (minimax.apiType || runtime.apiType || "openai-compatible").trim();
+  let apiType = rawApiType;
+  if (rawApiType === "openai-compatible" || rawApiType === "openai") {
+    apiType = "openai-completions";
+  }
+  mkdirSync(join(piDir, "extensions"), { recursive: true });
+  const models = {
+    providers: {
+      minimax: {
+        name: "MiniMax",
+        baseUrl: endpoint,
+        api: apiType,
+        apiKey: apiToken,
+        models: [{ id: modelId, name: modelId }],
+      },
+    },
+  };
+  writeFileSync(join(piDir, "models.json"), JSON.stringify(models, null, 2) + "\n");
+  let pool = runtime.pool;
+  if (Array.isArray(body.tavilyKeys)) {
+    pool = new TavilyKeyPool(body.tavilyKeys);
+  }
+  return {
+    ok: true,
+    endpoint,
+    model_id: modelId,
+    api: apiType,
+    has_token: Boolean(apiToken),
+    tavily: { active: pool.size() > 0, keys_count: pool.size() },
+    pool,
+  };
+}
+
+const dir = mkdtempSync(join(tmpdir(), "pi-reload-"));
+try {
+  const result = applyReloadLike(
+    {
+      minimax: {
+        endpoint: "https://example.test/v1",
+        apiKey: "sk-test-key",
+        modelId: "MiniMax-M2.7-highspeed",
+        apiType: "openai-compatible",
+      },
+      tavilyKeys: ["tvly-a", "tvly-b"],
+    },
+    {
+      piDir: dir,
+      runtime: {
+        endpoint: "https://old",
+        model: "old",
+        apiType: "openai-compatible",
+        pool: new TavilyKeyPool([]),
+      },
+    },
+  );
+  assert.equal(result.ok, true);
+  assert.equal(result.model_id, "MiniMax-M2.7-highspeed");
+  assert.equal(result.api, "openai-completions");
+  assert.equal(result.tavily.keys_count, 2);
+  const models = JSON.parse(readFileSync(join(dir, "models.json"), "utf8"));
+  assert.equal(models.providers.minimax.apiKey, "sk-test-key");
+  assert.equal(models.providers.minimax.baseUrl, "https://example.test/v1");
+  console.log("reload.test.mjs PASS");
+} finally {
+  rmSync(dir, { recursive: true, force: true });
+}
diff --git a/deploy/postgres/goose/00071_2026-09-25-maintenance-windows.sql b/deploy/postgres/goose/00071_2026-09-25-maintenance-windows.sql
new file mode 100644
index 00000000..fa6dcdfb
--- /dev/null
+++ b/deploy/postgres/goose/00071_2026-09-25-maintenance-windows.sql
@@ -0,0 +1,26 @@
+-- +goose Up
+CREATE TABLE IF NOT EXISTS maintenance_windows (
+    id               BIGSERIAL PRIMARY KEY,
+    scheduled_start  TIMESTAMPTZ NOT NULL,
+    scheduled_end    TIMESTAMPTZ NOT NULL,
+    message          TEXT NOT NULL DEFAULT '',
+    status           TEXT NOT NULL DEFAULT 'scheduled'
+                     CHECK (status IN ('scheduled', 'active', 'ended', 'cancelled')),
+    created_by       BIGINT,
+    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
+    activated_at     TIMESTAMPTZ,
+    ended_at         TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_maintenance_windows_status_start
+    ON maintenance_windows (status, scheduled_start);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_maintenance_windows_one_open
+    ON maintenance_windows ((1))
+    WHERE status IN ('scheduled', 'active');
+
+-- +goose Down
+DROP INDEX IF EXISTS uq_maintenance_windows_one_open;
+DROP INDEX IF EXISTS idx_maintenance_windows_status_start;
+DROP TABLE IF EXISTS maintenance_windows;
diff --git a/internal/app/app.go b/internal/app/app.go
index f855d855..54f2e597 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -30,6 +30,7 @@ import (
 	"chat-agent-system/internal/intent"
 	"chat-agent-system/internal/jevdecide"
 	"chat-agent-system/internal/kb"
+	"chat-agent-system/internal/maintenance"
 	"chat-agent-system/internal/notification"
 	"chat-agent-system/internal/supplementimpact"
 	"chat-agent-system/internal/observability"
@@ -83,6 +84,7 @@ type Runtime struct {
 	FreeDoc     *freedoc.Service
 	KB          *kb.Client
 	SysAdmin    *sysadmin.Service
+	Maintenance *maintenance.Service
 
 	mu             sync.Mutex
 	running        bool
@@ -289,6 +291,21 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
 		},
 		FeatureValues: sysadminFeatureValues(cfg),
 	})
+	if cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != "" {
+		rt.SysAdmin.SetApplyFn(sysadmin.NewRunnerApplyFunc(
+			rt.SysAdmin, cfg.PiAgentRunnerURL, cfg.PiAgentRunnerAuthToken, nil,
+		))
+		go func() {
+			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+			defer cancel()
+			if _, err := rt.SysAdmin.ApplyToRunner(ctx); err != nil {
+				fmt.Printf("sysadmin: initial runner apply: %v\n", err)
+			}
+		}()
+	}
+	if pool != nil {
+		rt.Maintenance = maintenance.NewService(maintenance.NewPGStore(pool))
+	}
 	if instSvc != nil {
 		instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
 		svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go
new file mode 100644
index 00000000..d4031bfe
--- /dev/null
+++ b/internal/handoff/handoff.go
@@ -0,0 +1,127 @@
+package handoff
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"strconv"
+	"syscall"
+	"time"
+)
+
+const (
+	EnvInheritFD   = "AGENT_INHERIT_FD"
+	EnvReadyPipeFD = "AGENT_READY_PIPE_FD"
+)
+
+// ListenOrAdopt 若 AGENT_INHERIT_FD 已设置则继承 listener,否则新建。
+func ListenOrAdopt(addr string) (net.Listener, error) {
+	if ln, ok, err := AdoptFromEnv(); ok {
+		return ln, err
+	}
+	return net.Listen("tcp", addr)
+}
+
+// AdoptFromEnv 从 AGENT_INHERIT_FD 继承 TCP listener。
+func AdoptFromEnv() (net.Listener, bool, error) {
+	raw := os.Getenv(EnvInheritFD)
+	if raw == "" {
+		return nil, false, nil
+	}
+	fd, err := strconv.Atoi(raw)
+	if err != nil {
+		return nil, true, fmt.Errorf("handoff: invalid %s: %w", EnvInheritFD, err)
+	}
+	f := os.NewFile(uintptr(fd), "inherit-listener")
+	if f == nil {
+		return nil, true, fmt.Errorf("handoff: NewFile(%d) failed", fd)
+	}
+	ln, err := net.FileListener(f)
+	_ = f.Close() // FileListener dup'd; close our copy
+	if err != nil {
+		return nil, true, err
+	}
+	_ = os.Unsetenv(EnvInheritFD)
+	return ln, true, nil
+}
+
+// PassToChild 把 listener FD 传给子进程,并等待 ready 信号(可选超时)。
+// extraEnv 追加到子进程环境;child 应调用 SignalReady 后父进程再返回。
+func PassToChild(ln net.Listener, bin string, args []string, extraEnv []string, readyTimeout time.Duration) (*exec.Cmd, error) {
+	tcp, ok := ln.(*net.TCPListener)
+	if !ok {
+		return nil, fmt.Errorf("handoff: listener is not *net.TCPListener")
+	}
+	file, err := tcp.File()
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	readyR, readyW, err := os.Pipe()
+	if err != nil {
+		return nil, err
+	}
+
+	cmd := exec.Command(bin, args...)
+	cmd.Env = append(os.Environ(), extraEnv...)
+	cmd.Env = append(cmd.Env,
+		fmt.Sprintf("%s=3", EnvInheritFD),
+		fmt.Sprintf("%s=4", EnvReadyPipeFD),
+	)
+	cmd.ExtraFiles = []*os.File{file, readyW}
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+	if err := cmd.Start(); err != nil {
+		_ = readyR.Close()
+		_ = readyW.Close()
+		return nil, err
+	}
+	_ = readyW.Close() // child holds the write end via ExtraFiles
+
+	if readyTimeout <= 0 {
+		readyTimeout = 15 * time.Second
+	}
+	done := make(chan error, 1)
+	go func() {
+		buf := make([]byte, 1)
+		_, err := readyR.Read(buf)
+		done <- err
+		_ = readyR.Close()
+	}()
+	select {
+	case err := <-done:
+		if err != nil {
+			_ = cmd.Process.Kill()
+			return cmd, fmt.Errorf("handoff: ready signal: %w", err)
+		}
+	case <-time.After(readyTimeout):
+		_ = cmd.Process.Kill()
+		_ = readyR.Close()
+		return cmd, fmt.Errorf("handoff: ready timeout")
+	}
+	return cmd, nil
+}
+
+// SignalReady 子进程通知父进程已接管 listener。
+func SignalReady() error {
+	raw := os.Getenv(EnvReadyPipeFD)
+	if raw == "" {
+		return nil
+	}
+	fd, err := strconv.Atoi(raw)
+	if err != nil {
+		return err
+	}
+	f := os.NewFile(uintptr(fd), "ready-pipe")
+	if f == nil {
+		return fmt.Errorf("handoff: ready pipe fd invalid")
+	}
+	defer f.Close()
+	_, err = f.Write([]byte{1})
+	_ = os.Unsetenv(EnvReadyPipeFD)
+	return err
+}
diff --git a/internal/handoff/handoff_e2e_test.go b/internal/handoff/handoff_e2e_test.go
new file mode 100644
index 00000000..fa382437
--- /dev/null
+++ b/internal/handoff/handoff_e2e_test.go
@@ -0,0 +1,183 @@
+package handoff_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"chat-agent-system/internal/handoff"
+)
+
+// TestRealFDHandoffE2E builds maintpage, hands off a live listener, then
+// starts a stub server via the maintpage admin token — zero-downtime port reuse.
+func TestRealFDHandoffE2E(t *testing.T) {
+	if testing.Short() {
+		t.Skip("real handoff e2e")
+	}
+	modRoot, err := filepath.Abs("../..")
+	if err != nil {
+		t.Fatal(err)
+	}
+	tmp := t.TempDir()
+	maintBin := filepath.Join(tmp, "maintpage")
+	stubBin := filepath.Join(tmp, "stubserver")
+
+	build := func(out, pkg string) {
+		cmd := exec.Command("go", "build", "-o", out, pkg)
+		cmd.Dir = modRoot
+		if o, err := cmd.CombinedOutput(); err != nil {
+			t.Fatalf("build %s: %v\n%s", pkg, err, o)
+		}
+	}
+	build(maintBin, "./cmd/maintpage")
+
+	stubSrc := filepath.Join(tmp, "stub.go")
+	_ = os.WriteFile(stubSrc, []byte(`package main
+import (
+  "fmt"
+  "net/http"
+  "os"
+  "time"
+  "chat-agent-system/internal/handoff"
+)
+func main() {
+  ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
+  if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
+  _ = handoff.SignalReady()
+  mux := http.NewServeMux()
+  mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
+  mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
+  _ = http.Serve(ln, mux)
+  time.Sleep(time.Hour)
+}
+`), 0o644)
+	// stub must live inside module for internal import — copy into cmd temp under module
+	stubPkgDir := filepath.Join(modRoot, "cmd", "_handoff_e2e_stub")
+	_ = os.RemoveAll(stubPkgDir)
+	if err := os.MkdirAll(stubPkgDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(stubPkgDir)
+	if err := os.WriteFile(filepath.Join(stubPkgDir, "main.go"), []byte(`package main
+import (
+  "fmt"
+  "net/http"
+  "os"
+  "time"
+  "chat-agent-system/internal/handoff"
+)
+func main() {
+  ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
+  if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
+  _ = handoff.SignalReady()
+  mux := http.NewServeMux()
+  mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
+	mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
+  go func() { _ = http.Serve(ln, mux) }()
+  // Keep alive until killed by test cleanup.
+  select {}
+}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	build(stubBin, "./cmd/_handoff_e2e_stub")
+
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	addr := ln.Addr().String()
+	token := fmt.Sprintf("e2e-%d", time.Now().UnixNano())
+	dataDir := filepath.Join(tmp, "data")
+	_ = os.MkdirAll(dataDir, 0o700)
+
+	extra := []string{
+		"MAINT_LISTEN_ADDR=" + addr,
+		"MAINT_MESSAGE=handoff-e2e",
+		"MAINT_ADMIN_TOKEN=" + token,
+		"MAINT_WINDOW_ID=1",
+		"MAINT_SERVER_BIN=" + stubBin,
+		"DATA_DIR=" + dataDir,
+	}
+	cmd, err := handoff.PassToChild(ln, maintBin, nil, extra, 20*time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() {
+		if cmd.Process != nil {
+			_ = cmd.Process.Kill()
+			_, _ = cmd.Process.Wait()
+		}
+		// stub may have replaced maintpage; best-effort kill by port holders is out of scope —
+		// stub exits when we kill its process via token start response pid if present.
+	})
+	_ = ln.Close()
+
+	deadline := time.Now().Add(8 * time.Second)
+	var status map[string]any
+	for time.Now().Before(deadline) {
+		resp, err := http.Get("http://" + addr + "/api/v1/maintenance/status")
+		if err == nil {
+			body, _ := io.ReadAll(resp.Body)
+			_ = resp.Body.Close()
+			if resp.StatusCode == 200 && json.Unmarshal(body, &status) == nil && status["active"] == true {
+				break
+			}
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+	if status["active"] != true {
+		t.Fatalf("maintpage not active: %+v", status)
+	}
+
+	req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/api/v1/maintenance/admin/start", nil)
+	req.Header.Set("Authorization", "Bearer "+token)
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	startBody, _ := io.ReadAll(resp.Body)
+	_ = resp.Body.Close()
+	if resp.StatusCode != 200 {
+		t.Fatalf("start: %d %s", resp.StatusCode, startBody)
+	}
+	var startResp struct {
+		OK  bool `json:"ok"`
+		PID int  `json:"pid"`
+	}
+	_ = json.Unmarshal(startBody, &startResp)
+	if startResp.PID > 0 {
+		t.Cleanup(func() {
+			proc, err := os.FindProcess(startResp.PID)
+			if err == nil && proc != nil {
+				_ = proc.Kill()
+			}
+		})
+	}
+
+	deadline = time.Now().Add(8 * time.Second)
+	ok := false
+	for time.Now().Before(deadline) {
+		resp, err := http.Get("http://" + addr + "/api/v1/health")
+		if err == nil {
+			body, _ := io.ReadAll(resp.Body)
+			_ = resp.Body.Close()
+			var h map[string]any
+			if resp.StatusCode == 200 && json.Unmarshal(body, &h) == nil && h["ok"] == true {
+				ok = true
+				break
+			}
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+	if !ok {
+		t.Fatal("stub server health not ok after handoff resume")
+	}
+}
diff --git a/internal/handoff/handoff_test.go b/internal/handoff/handoff_test.go
new file mode 100644
index 00000000..d9d7c4d9
--- /dev/null
+++ b/internal/handoff/handoff_test.go
@@ -0,0 +1,47 @@
+package handoff_test
+
+import (
+	"net"
+	"os"
+	"testing"
+
+	"chat-agent-system/internal/handoff"
+)
+
+func TestListenOrAdoptFresh(t *testing.T) {
+	_ = os.Unsetenv(handoff.EnvInheritFD)
+	ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ln.Close()
+	tcp, ok := ln.(*net.TCPListener)
+	if !ok {
+		t.Fatal("expected TCPListener")
+	}
+	f, err := tcp.File()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	adopted, err := net.FileListener(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer adopted.Close()
+}
+
+func TestSignalReadyNoopWithoutEnv(t *testing.T) {
+	_ = os.Unsetenv(handoff.EnvReadyPipeFD)
+	if err := handoff.SignalReady(); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestAdoptFromEnvMissing(t *testing.T) {
+	_ = os.Unsetenv(handoff.EnvInheritFD)
+	ln, ok, err := handoff.AdoptFromEnv()
+	if ok || ln != nil || err != nil {
+		t.Fatalf("expected no adopt, got ok=%v ln=%v err=%v", ok, ln, err)
+	}
+}
diff --git a/internal/maintenance/service.go b/internal/maintenance/service.go
new file mode 100644
index 00000000..5aa3eca0
--- /dev/null
+++ b/internal/maintenance/service.go
@@ -0,0 +1,180 @@
+package maintenance
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+)
+
+// HandoffFunc 到点时调用:负责 spawn 公告进程并移交 listener。
+type HandoffFunc func(ctx context.Context, w Window) error
+
+// Service 维护窗口调度。
+type Service struct {
+	store   Store
+	now     func() time.Time
+	handoff HandoffFunc
+
+	mu     sync.Mutex
+	timer  *time.Timer
+	stopCh chan struct{}
+}
+
+func NewService(store Store) *Service {
+	return &Service{
+		store:  store,
+		now:    time.Now,
+		stopCh: make(chan struct{}),
+	}
+}
+
+func (s *Service) SetHandoff(fn HandoffFunc) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.handoff = fn
+}
+
+// StatusView 公开状态(登录页/横幅)。
+type StatusView struct {
+	Active         bool       `json:"active"`
+	Scheduled      bool       `json:"scheduled"`
+	Message        string     `json:"message"`
+	ScheduledStart *time.Time `json:"scheduledStart,omitempty"`
+	ScheduledEnd   *time.Time `json:"scheduledEnd,omitempty"`
+	ExpectedHours  float64    `json:"expectedHours,omitempty"`
+	WindowID       int64      `json:"windowId,omitempty"`
+	Status         string     `json:"status"`
+}
+
+func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) {
+	w, err := s.store.GetOpen(ctx)
+	if err != nil {
+		if errors.Is(err, ErrNotFound) {
+			return StatusView{Status: "none"}, nil
+		}
+		return StatusView{}, err
+	}
+	hours := w.ScheduledEnd.Sub(w.ScheduledStart).Hours()
+	view := StatusView{
+		Message:        w.Message,
+		ScheduledStart: &w.ScheduledStart,
+		ScheduledEnd:   &w.ScheduledEnd,
+		ExpectedHours:  hours,
+		WindowID:       w.ID,
+		Status:         w.Status,
+	}
+	switch w.Status {
+	case StatusActive:
+		view.Active = true
+	case StatusScheduled:
+		view.Scheduled = true
+	}
+	return view, nil
+}
+
+type ScheduleInput struct {
+	Start     time.Time
+	End       time.Time
+	Message   string
+	CreatedBy int64
+}
+
+func (s *Service) Schedule(ctx context.Context, in ScheduleInput) (Window, error) {
+	if in.End.Before(in.Start) || !in.End.After(in.Start) {
+		return Window{}, ErrInvalidWindow
+	}
+	if in.Start.Before(s.now().Add(-time.Minute)) {
+		return Window{}, fmt.Errorf("%w: start must be in the future", ErrInvalidWindow)
+	}
+	createdBy := in.CreatedBy
+	w, err := s.store.Insert(ctx, Window{
+		ScheduledStart: in.Start.UTC(),
+		ScheduledEnd:   in.End.UTC(),
+		Message:        in.Message,
+		CreatedBy:      &createdBy,
+	})
+	if err != nil {
+		return Window{}, err
+	}
+	s.Arm(ctx)
+	return w, nil
+}
+
+func (s *Service) Cancel(ctx context.Context, id int64) (Window, error) {
+	w, err := s.store.UpdateStatus(ctx, id, StatusScheduled, StatusCancelled)
+	if err != nil {
+		return Window{}, err
+	}
+	s.Arm(ctx)
+	return w, nil
+}
+
+func (s *Service) MarkActive(ctx context.Context, id int64) (Window, error) {
+	return s.store.UpdateStatus(ctx, id, StatusScheduled, StatusActive)
+}
+
+func (s *Service) MarkEnded(ctx context.Context, id int64) (Window, error) {
+	return s.store.UpdateStatus(ctx, id, StatusActive, StatusEnded)
+}
+
+func (s *Service) ListRecent(ctx context.Context, limit int) ([]Window, error) {
+	return s.store.ListRecent(ctx, limit)
+}
+
+// Arm 根据最近 scheduled 窗口重设 timer。
+func (s *Service) Arm(ctx context.Context) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.timer != nil {
+		s.timer.Stop()
+		s.timer = nil
+	}
+	w, err := s.store.GetOpen(ctx)
+	if err != nil || w == nil || w.Status != StatusScheduled {
+		return
+	}
+	delay := time.Until(w.ScheduledStart)
+	if delay < 0 {
+		delay = 0
+	}
+	id := w.ID
+	s.timer = time.AfterFunc(delay, func() {
+		s.fire(id)
+	})
+}
+
+func (s *Service) fire(id int64) {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	w, err := s.MarkActive(ctx, id)
+	if err != nil {
+		return
+	}
+	s.mu.Lock()
+	fn := s.handoff
+	s.mu.Unlock()
+	if fn != nil {
+		_ = fn(ctx, w)
+	}
+}
+
+// Start 启动时恢复 timer;Stop 停止。
+func (s *Service) Start(ctx context.Context) {
+	s.Arm(ctx)
+}
+
+func (s *Service) Stop() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.timer != nil {
+		s.timer.Stop()
+		s.timer = nil
+	}
+	select {
+	case <-s.stopCh:
+	default:
+		close(s.stopCh)
+	}
+}
diff --git a/internal/maintenance/service_test.go b/internal/maintenance/service_test.go
new file mode 100644
index 00000000..f22659b1
--- /dev/null
+++ b/internal/maintenance/service_test.go
@@ -0,0 +1,124 @@
+package maintenance
+
+import (
+	"context"
+	"sync"
+	"testing"
+	"time"
+)
+
+type memStore struct {
+	mu   sync.Mutex
+	next int64
+	rows map[int64]Window
+}
+
+func newMem() *memStore {
+	return &memStore{rows: map[int64]Window{}, next: 1}
+}
+
+func (m *memStore) GetOpen(_ context.Context) (*Window, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, w := range m.rows {
+		if w.Status == StatusScheduled || w.Status == StatusActive {
+			cp := w
+			return &cp, nil
+		}
+	}
+	return nil, ErrNotFound
+}
+
+func (m *memStore) GetByID(_ context.Context, id int64) (*Window, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	w, ok := m.rows[id]
+	if !ok {
+		return nil, ErrNotFound
+	}
+	cp := w
+	return &cp, nil
+}
+
+func (m *memStore) Insert(_ context.Context, in Window) (Window, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, w := range m.rows {
+		if w.Status == StatusScheduled || w.Status == StatusActive {
+			return Window{}, ErrConflictOpen
+		}
+	}
+	in.ID = m.next
+	m.next++
+	in.Status = StatusScheduled
+	in.CreatedAt = time.Now()
+	in.UpdatedAt = in.CreatedAt
+	m.rows[in.ID] = in
+	return in, nil
+}
+
+func (m *memStore) UpdateStatus(_ context.Context, id int64, from, to string) (Window, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	w, ok := m.rows[id]
+	if !ok || w.Status != from {
+		return Window{}, ErrBadStatus
+	}
+	w.Status = to
+	w.UpdatedAt = time.Now()
+	now := w.UpdatedAt
+	if to == StatusActive {
+		w.ActivatedAt = &now
+	}
+	if to == StatusEnded || to == StatusCancelled {
+		w.EndedAt = &now
+	}
+	m.rows[id] = w
+	return w, nil
+}
+
+func (m *memStore) ListRecent(_ context.Context, limit int) ([]Window, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	out := make([]Window, 0, len(m.rows))
+	for _, w := range m.rows {
+		out = append(out, w)
+	}
+	if limit > 0 && len(out) > limit {
+		out = out[:limit]
+	}
+	return out, nil
+}
+
+func TestScheduleAndStatus(t *testing.T) {
+	svc := NewService(newMem())
+	start := time.Now().Add(2 * time.Hour)
+	end := start.Add(3 * time.Hour)
+	w, err := svc.Schedule(context.Background(), ScheduleInput{
+		Start: start, End: end, Message: "升级", CreatedBy: 1,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	view, err := svc.CurrentStatus(context.Background())
+	if err != nil || !view.Scheduled || view.WindowID != w.ID {
+		t.Fatalf("view=%+v err=%v", view, err)
+	}
+	if _, err := svc.Cancel(context.Background(), w.ID); err != nil {
+		t.Fatal(err)
+	}
+	view, _ = svc.CurrentStatus(context.Background())
+	if view.Scheduled || view.Active {
+		t.Fatalf("expected cleared, got %+v", view)
+	}
+}
+
+func TestInvalidWindow(t *testing.T) {
+	svc := NewService(newMem())
+	_, err := svc.Schedule(context.Background(), ScheduleInput{
+		Start: time.Now().Add(time.Hour), End: time.Now(), Message: "x",
+	})
+	if err == nil {
+		t.Fatal("expected error")
+	}
+}
diff --git a/internal/maintenance/store.go b/internal/maintenance/store.go
new file mode 100644
index 00000000..26a58896
--- /dev/null
+++ b/internal/maintenance/store.go
@@ -0,0 +1,195 @@
+package maintenance
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+)
+
+var (
+	ErrNotFound      = errors.New("maintenance: window not found")
+	ErrConflictOpen  = errors.New("maintenance: an open window already exists")
+	ErrInvalidWindow = errors.New("maintenance: invalid schedule")
+	ErrBadStatus     = errors.New("maintenance: invalid status transition")
+)
+
+const (
+	StatusScheduled = "scheduled"
+	StatusActive    = "active"
+	StatusEnded     = "ended"
+	StatusCancelled = "cancelled"
+)
+
+// Window 维护窗口行。
+type Window struct {
+	ID             int64      `json:"id"`
+	ScheduledStart time.Time  `json:"scheduledStart"`
+	ScheduledEnd   time.Time  `json:"scheduledEnd"`
+	Message        string     `json:"message"`
+	Status         string     `json:"status"`
+	CreatedBy      *int64     `json:"createdBy,omitempty"`
+	CreatedAt      time.Time  `json:"createdAt"`
+	UpdatedAt      time.Time  `json:"updatedAt"`
+	ActivatedAt    *time.Time `json:"activatedAt,omitempty"`
+	EndedAt        *time.Time `json:"endedAt,omitempty"`
+}
+
+// Store 持久化接口。
+type Store interface {
+	GetOpen(ctx context.Context) (*Window, error)
+	GetByID(ctx context.Context, id int64) (*Window, error)
+	Insert(ctx context.Context, w Window) (Window, error)
+	UpdateStatus(ctx context.Context, id int64, from, to string) (Window, error)
+	ListRecent(ctx context.Context, limit int) ([]Window, error)
+}
+
+type pgStore struct {
+	pool *pgxpool.Pool
+}
+
+func NewPGStore(pool *pgxpool.Pool) Store {
+	return &pgStore{pool: pool}
+}
+
+func scanWindow(row pgx.Row) (Window, error) {
+	var w Window
+	var createdBy *int64
+	var activated, ended *time.Time
+	err := row.Scan(
+		&w.ID, &w.ScheduledStart, &w.ScheduledEnd, &w.Message, &w.Status,
+		&createdBy, &w.CreatedAt, &w.UpdatedAt, &activated, &ended,
+	)
+	if err != nil {
+		return Window{}, err
+	}
+	w.CreatedBy = createdBy
+	w.ActivatedAt = activated
+	w.EndedAt = ended
+	return w, nil
+}
+
+func (s *pgStore) GetOpen(ctx context.Context) (*Window, error) {
+	w, err := scanWindow(s.pool.QueryRow(ctx, `
+		SELECT id, scheduled_start, scheduled_end, message, status,
+		       created_by, created_at, updated_at, activated_at, ended_at
+		FROM maintenance_windows
+		WHERE status IN ('scheduled', 'active')
+		ORDER BY scheduled_start ASC
+		LIMIT 1
+	`))
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return nil, ErrNotFound
+		}
+		return nil, err
+	}
+	return &w, nil
+}
+
+func (s *pgStore) GetByID(ctx context.Context, id int64) (*Window, error) {
+	w, err := scanWindow(s.pool.QueryRow(ctx, `
+		SELECT id, scheduled_start, scheduled_end, message, status,
+		       created_by, created_at, updated_at, activated_at, ended_at
+		FROM maintenance_windows WHERE id = $1
+	`, id))
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return nil, ErrNotFound
+		}
+		return nil, err
+	}
+	return &w, nil
+}
+
+func (s *pgStore) Insert(ctx context.Context, in Window) (Window, error) {
+	w, err := scanWindow(s.pool.QueryRow(ctx, `
+		INSERT INTO maintenance_windows (scheduled_start, scheduled_end, message, status, created_by)
+		VALUES ($1, $2, $3, $4, $5)
+		RETURNING id, scheduled_start, scheduled_end, message, status,
+		          created_by, created_at, updated_at, activated_at, ended_at
+	`, in.ScheduledStart, in.ScheduledEnd, in.Message, StatusScheduled, in.CreatedBy))
+	if err != nil {
+		// unique partial index → conflict
+		if isUniqueViolation(err) {
+			return Window{}, ErrConflictOpen
+		}
+		return Window{}, err
+	}
+	return w, nil
+}
+
+func (s *pgStore) UpdateStatus(ctx context.Context, id int64, from, to string) (Window, error) {
+	var q string
+	switch to {
+	case StatusActive:
+		q = `
+			UPDATE maintenance_windows
+			SET status = $2, updated_at = now(), activated_at = now()
+			WHERE id = $1 AND status = $3
+			RETURNING id, scheduled_start, scheduled_end, message, status,
+			          created_by, created_at, updated_at, activated_at, ended_at`
+	case StatusEnded, StatusCancelled:
+		q = `
+			UPDATE maintenance_windows
+			SET status = $2, updated_at = now(), ended_at = now()
+			WHERE id = $1 AND status = $3
+			RETURNING id, scheduled_start, scheduled_end, message, status,
+			          created_by, created_at, updated_at, activated_at, ended_at`
+	default:
+		return Window{}, ErrBadStatus
+	}
+	w, err := scanWindow(s.pool.QueryRow(ctx, q, id, to, from))
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return Window{}, ErrBadStatus
+		}
+		return Window{}, err
+	}
+	return w, nil
+}
+
+func (s *pgStore) ListRecent(ctx context.Context, limit int) ([]Window, error) {
+	if limit <= 0 {
+		limit = 20
+	}
+	rows, err := s.pool.Query(ctx, `
+		SELECT id, scheduled_start, scheduled_end, message, status,
+		       created_by, created_at, updated_at, activated_at, ended_at
+		FROM maintenance_windows
+		ORDER BY scheduled_start DESC
+		LIMIT $1
+	`, limit)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	out := make([]Window, 0)
+	for rows.Next() {
+		w, err := scanWindow(rows)
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, w)
+	}
+	return out, rows.Err()
+}
+
+func isUniqueViolation(err error) bool {
+	return err != nil && (contains(err.Error(), "uq_maintenance_windows_one_open") || contains(err.Error(), "unique"))
+}
+
+func contains(s, sub string) bool {
+	return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
+}
+
+func indexOf(s, sub string) int {
+	for i := 0; i+len(sub) <= len(s); i++ {
+		if s[i:i+len(sub)] == sub {
+			return i
+		}
+	}
+	return -1
+}
diff --git a/internal/server/admin_system_handlers_test.go b/internal/server/admin_system_handlers_test.go
index db3c5168..91ea5751 100644
--- a/internal/server/admin_system_handlers_test.go
+++ b/internal/server/admin_system_handlers_test.go
@@ -45,26 +45,35 @@ func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage,
 	return append(json.RawMessage(nil), entry.value...), &by, &at, entry.revision, nil
 }
 
-func (f *fakeSettingsStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) {
+func (f *fakeSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) {
+	return f.UpsertWithAudit(ctx, key, value, value, updatedBy, "")
+}
+
+func (f *fakeSettingsStore) UpsertWithAudit(_ context.Context, key string, value, auditAfter json.RawMessage, updatedBy int64, requestID string) (time.Time, int64, error) {
 	f.mu.Lock()
 	defer f.mu.Unlock()
-	prev := f.items[key]
+	prev, exists := f.items[key]
 	before := append(json.RawMessage(nil), prev.value...)
 	after := append(json.RawMessage(nil), value...)
+	rev := int64(0)
+	if exists {
+		rev = prev.revision + 1
+	}
 	f.items[key] = struct {
 		value    json.RawMessage
 		revision int64
-	}{value: after, revision: prev.revision}
+	}{value: after, revision: rev}
 	f.nextID++
 	f.audits = append(f.audits, sysadmin.PolicyAudit{
 		ID:          f.nextID,
 		SettingKey:  key,
 		BeforeValue: before,
-		AfterValue:  after,
+		AfterValue:  append(json.RawMessage(nil), auditAfter...),
 		ActorUserID: updatedBy,
+		RequestID:   requestID,
 		CreatedAt:   time.Now(),
 	})
-	return time.Now(), prev.revision, nil
+	return time.Now(), rev, nil
 }
 
 func (f *fakeSettingsStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) {
diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go
index 08dc9d82..db34e68e 100644
--- a/internal/server/auth_middleware.go
+++ b/internal/server/auth_middleware.go
@@ -20,7 +20,8 @@ func (s *Server) allowLegacyTestPrincipal() bool {
 func isPublicRoute(method, path string) bool {
 	if path == "/" || path == "/health" || path == "/api/v1/health" ||
 		path == "/ready" || path == "/api/v1/ready" ||
-		path == "/api/v1/vault/status" {
+		path == "/api/v1/vault/status" ||
+		path == "/api/v1/maintenance/status" {
 		return true
 	}
 	if method == http.MethodPost && path == "/api/v1/auth/login" {
diff --git a/internal/server/maintenance_handlers.go b/internal/server/maintenance_handlers.go
new file mode 100644
index 00000000..4a633659
--- /dev/null
+++ b/internal/server/maintenance_handlers.go
@@ -0,0 +1,238 @@
+package server
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"chat-agent-system/internal/config"
+	"chat-agent-system/internal/handoff"
+	"chat-agent-system/internal/maintenance"
+)
+
+func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request) {
+	if s.maintenance == nil {
+		writeJSONResponse(w, http.StatusOK, maintenance.StatusView{Status: "none"})
+		return
+	}
+	view, err := s.maintenance.CurrentStatus(r.Context())
+	if err != nil {
+		writeSafeError(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSONResponse(w, http.StatusOK, view)
+}
+
+func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Request) {
+	if _, ok := s.requireSystemAdmin(w, r); !ok {
+		return
+	}
+	if s.maintenance == nil {
+		writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable")
+		return
+	}
+	list, err := s.maintenance.ListRecent(r.Context(), 20)
+	if err != nil {
+		writeSafeError(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSONResponse(w, http.StatusOK, map[string]any{"windows": list})
+}
+
+func (s *Server) handleAdminMaintenanceSchedule(w http.ResponseWriter, r *http.Request) {
+	user, ok := s.requireSystemAdmin(w, r)
+	if !ok {
+		return
+	}
+	if s.maintenance == nil {
+		writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable")
+		return
+	}
+	var req struct {
+		Start   time.Time `json:"start"`
+		End     time.Time `json:"end"`
+		Hours   float64   `json:"hours"`
+		Message string    `json:"message"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
+		return
+	}
+	end := req.End
+	if end.IsZero() && req.Hours > 0 {
+		end = req.Start.Add(time.Duration(req.Hours * float64(time.Hour)))
+	}
+	wnd, err := s.maintenance.Schedule(r.Context(), maintenance.ScheduleInput{
+		Start: req.Start, End: end, Message: req.Message, CreatedBy: user.ID,
+	})
+	switch {
+	case errors.Is(err, maintenance.ErrInvalidWindow):
+		writeSafeError(w, r, http.StatusBadRequest, err.Error())
+	case errors.Is(err, maintenance.ErrConflictOpen):
+		writeSafeError(w, r, http.StatusConflict, "已有未结束的维护窗口")
+	case err != nil:
+		writeSafeError(w, r, http.StatusInternalServerError, err.Error())
+	default:
+		writeJSONResponse(w, http.StatusOK, wnd)
+	}
+}
+
+func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Request) {
+	if _, ok := s.requireSystemAdmin(w, r); !ok {
+		return
+	}
+	if s.maintenance == nil {
+		writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable")
+		return
+	}
+	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+	if err != nil {
+		writeSafeError(w, r, http.StatusBadRequest, "无效的 id")
+		return
+	}
+	wnd, err := s.maintenance.Cancel(r.Context(), id)
+	switch {
+	case errors.Is(err, maintenance.ErrBadStatus):
+		writeSafeError(w, r, http.StatusConflict, "当前状态不可取消")
+	case err != nil:
+		writeSafeError(w, r, http.StatusInternalServerError, err.Error())
+	default:
+		writeJSONResponse(w, http.StatusOK, wnd)
+	}
+}
+
+func (s *Server) maintenanceMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if s.maintenance == nil {
+			next.ServeHTTP(w, r)
+			return
+		}
+		view, err := s.maintenance.CurrentStatus(r.Context())
+		if err != nil || !view.Active {
+			next.ServeHTTP(w, r)
+			return
+		}
+		path := r.URL.Path
+		if isMaintenanceExempt(r.Method, path) {
+			next.ServeHTTP(w, r)
+			return
+		}
+		// 管理员仍可访问管理台与维护 API
+		if user, err := s.authenticateUser(r); err == nil && user != nil {
+			if s.authzSvc != nil {
+				if admin, aerr := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID); aerr == nil && admin {
+					next.ServeHTTP(w, r)
+					return
+				}
+			}
+		}
+		if r.Method == http.MethodGet && !strings.HasPrefix(path, "/api/") {
+			next.ServeHTTP(w, r)
+			return
+		}
+		w.Header().Set("Retry-After", "3600")
+		writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{
+			"error":   "MAINTENANCE",
+			"message": view.Message,
+			"status":  view,
+		})
+	})
+}
+
+func isMaintenanceExempt(method, path string) bool {
+	if path == "/health" || path == "/api/v1/health" || path == "/ready" || path == "/api/v1/ready" {
+		return true
+	}
+	if path == "/api/v1/maintenance/status" {
+		return true
+	}
+	if method == http.MethodPost && (path == "/api/v1/auth/login" || path == "/api/v1/auth/register") {
+		return true
+	}
+	if method == http.MethodGet && path == "/api/v1/auth/public-config" {
+		return true
+	}
+	if strings.HasPrefix(path, "/api/v1/admin/system") || strings.HasPrefix(path, "/api/v1/admin/maintenance") {
+		return true
+	}
+	return false
+}
+
+func (s *Server) performMaintenanceHandoff(w maintenance.Window) error {
+	token, err := randomToken(24)
+	if err != nil {
+		return err
+	}
+	dataDir := config.DefaultDataDir()
+	if s.cfg != nil && s.cfg.DataDir != "" {
+		dataDir = s.cfg.DataDir
+	}
+	tokenPath := filepath.Join(dataDir, "maintenance-token")
+	if err := os.MkdirAll(dataDir, 0o700); err != nil {
+		return err
+	}
+	if err := os.WriteFile(tokenPath, []byte(token+"\n"), 0o600); err != nil {
+		return err
+	}
+	s.lastMaintToken = token
+
+	bin := envOr("MAINTPAGE_BIN", "./bin/maintpage")
+	if abs, err := filepath.Abs(bin); err == nil {
+		bin = abs
+	}
+	serverBin := envOr("AGENT_SERVER_BIN", "./bin/agent-server")
+	if abs, err := filepath.Abs(serverBin); err == nil {
+		serverBin = abs
+	}
+	addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort)
+	extra := []string{
+		"MAINT_LISTEN_ADDR=" + addr,
+		"MAINT_MESSAGE=" + w.Message,
+		"MAINT_START=" + w.ScheduledStart.Format(time.RFC3339),
+		"MAINT_END=" + w.ScheduledEnd.Format(time.RFC3339),
+		fmt.Sprintf("MAINT_WINDOW_ID=%d", w.ID),
+		"MAINT_ADMIN_TOKEN=" + token,
+		"MAINT_SERVER_BIN=" + serverBin,
+		"DATA_DIR=" + dataDir,
+	}
+	if s.listener == nil {
+		return fmt.Errorf("maintenance: no listener to hand off")
+	}
+	cmd, err := handoff.PassToChild(s.listener, bin, nil, extra, 20*time.Second)
+	if err != nil {
+		return err
+	}
+	fmt.Printf("maintenance: handed off to maintpage pid=%d window=%d\n", cmd.Process.Pid, w.ID)
+	// 触发进程退出:关闭 HTTP(会关本端 listener FD;子进程持有副本)
+	go func() {
+		ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+		defer cancel()
+		_ = s.Shutdown(ctx)
+		os.Exit(0)
+	}()
+	return nil
+}
+
+func randomToken(n int) (string, error) {
+	b := make([]byte, n)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(b), nil
+}
+
+func envOr(k, def string) string {
+	if v := strings.TrimSpace(os.Getenv(k)); v != "" {
+		return v
+	}
+	return def
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 4aa94277..f09c15b2 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -3,7 +3,11 @@ package server
 import (
 	"context"
 	"fmt"
+	"net"
 	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
 	"sync"
 	"time"
 
@@ -22,8 +26,10 @@ import (
 	"chat-agent-system/internal/docgov"
 	"chat-agent-system/internal/docrevision"
 	"chat-agent-system/internal/freedoc"
+	"chat-agent-system/internal/handoff"
 	"chat-agent-system/internal/intent"
 	"chat-agent-system/internal/kb"
+	"chat-agent-system/internal/maintenance"
 	"chat-agent-system/internal/notification"
 	"chat-agent-system/internal/projectinstance"
 	"chat-agent-system/internal/projectworkflow"
@@ -67,6 +73,9 @@ type Server struct {
 	freeDoc            *freedoc.Service
 	kb                 *kb.Client
 	sysadmin           *sysadmin.Service
+	maintenance        *maintenance.Service
+	listener           net.Listener
+	lastMaintToken     string
 	wsReg              *workspace.FileRegistry
 	wsGCStop           chan struct{}
 	httpServer         *http.Server
@@ -128,6 +137,7 @@ func NewFromRuntime(rt *app.Runtime) *Server {
 		freeDoc:          rt.FreeDoc,
 		kb:               rt.KB,
 		sysadmin:         rt.SysAdmin,
+		maintenance:      rt.Maintenance,
 		wsReg:            workspaceRegistryFor(rt.Cfg),
 		intentPlans:      rt.IntentPlans,
 		// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
@@ -374,19 +384,38 @@ func (s *Server) Routes() http.Handler {
 	mux.HandleFunc("GET /api/v1/admin/system/settings/{key}/audits", s.handleAdminSystemSettingAudits)
 	mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey)
 
+	mux.HandleFunc("GET /api/v1/maintenance/status", s.handleMaintenanceStatus)
+	mux.HandleFunc("GET /api/v1/admin/maintenance", s.handleAdminMaintenanceList)
+	mux.HandleFunc("POST /api/v1/admin/maintenance", s.handleAdminMaintenanceSchedule)
+	mux.HandleFunc("POST /api/v1/admin/maintenance/{id}/cancel", s.handleAdminMaintenanceCancel)
+
 	// 10. 现代 SPA 前端页面与静态资产托管
 	mux.HandleFunc("GET /", s.serveSPA)
 
-	return s.corsMiddleware(s.accessLogMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))))
+	return s.corsMiddleware(s.accessLogMiddleware(s.bodyLimitMiddleware(s.maintenanceMiddleware(s.authMiddleware(mux)))))
 }
 
-// Start 启动 HTTP 守护进程
+// Start 启动 HTTP 守护进程(支持 AGENT_INHERIT_FD 继承 listener)
 func (s *Server) Start() error {
-	// Workers are started by cmd/server before Listen. Runtime.Start is
-	// idempotent, but calling it here swallowed the first error and raced
-	// with main. Do not start again.
 	s.startWorkspaceGC()
 	addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort)
+	ln, err := handoff.ListenOrAdopt(addr)
+	if err != nil {
+		return err
+	}
+	s.listener = ln
+	_ = handoff.SignalReady()
+
+	// 若从维护公告恢复,标记窗口 ended 并清理 token
+	s.finishMaintenanceResume()
+
+	if s.maintenance != nil {
+		s.maintenance.SetHandoff(func(ctx context.Context, w maintenance.Window) error {
+			return s.performMaintenanceHandoff(w)
+		})
+		s.maintenance.Start(context.Background())
+	}
+
 	s.httpServer = &http.Server{
 		Addr:              addr,
 		Handler:           s.Routes(),
@@ -396,7 +425,26 @@ func (s *Server) Start() error {
 		IdleTimeout:       120 * time.Second,
 	}
 
-	return s.httpServer.ListenAndServe()
+	return s.httpServer.Serve(ln)
+}
+
+func (s *Server) finishMaintenanceResume() {
+	if os.Getenv("MAINT_RESUME") != "1" || s.maintenance == nil {
+		return
+	}
+	id, _ := strconv.ParseInt(os.Getenv("MAINT_WINDOW_ID"), 10, 64)
+	if id > 0 {
+		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		_, _ = s.maintenance.MarkEnded(ctx, id)
+	}
+	dataDir := config.DefaultDataDir()
+	if s.cfg != nil && s.cfg.DataDir != "" {
+		dataDir = s.cfg.DataDir
+	}
+	_ = os.Remove(filepath.Join(dataDir, "maintenance-token"))
+	_ = os.Unsetenv("MAINT_RESUME")
+	_ = os.Unsetenv("MAINT_WINDOW_ID")
 }
 
 // Shutdown 优雅停机
@@ -409,6 +457,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
 		close(s.reminderStop)
 		s.reminderStop = nil
 	}
+	if s.maintenance != nil {
+		s.maintenance.Stop()
+	}
 	if s.vaultStop != nil {
 		close(s.vaultStop)
 		s.vaultStop = nil
diff --git a/internal/sysadmin/apply.go b/internal/sysadmin/apply.go
new file mode 100644
index 00000000..755f79ea
--- /dev/null
+++ b/internal/sysadmin/apply.go
@@ -0,0 +1,90 @@
+package sysadmin
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// RunnerApplyPayload 对齐 deploy/pi-agent/runner.mjs POST /admin/reload。
+type RunnerApplyPayload struct {
+	Minimax struct {
+		Endpoint string `json:"endpoint"`
+		APIKey   string `json:"apiKey"`
+		ModelID  string `json:"modelId"`
+		APIType  string `json:"apiType"`
+	} `json:"minimax"`
+	TavilyKeys []string `json:"tavilyKeys"`
+	PIModel    string   `json:"piModel,omitempty"`
+}
+
+// NewRunnerApplyFunc 构造推送到 PI runner 的 ApplyFunc。
+func NewRunnerApplyFunc(svc *Service, runnerURL, authToken string, client *http.Client) ApplyFunc {
+	if client == nil {
+		client = &http.Client{Timeout: 8 * time.Second}
+	}
+	base := strings.TrimRight(strings.TrimSpace(runnerURL), "/")
+	token := strings.TrimSpace(authToken)
+	return func(ctx context.Context) (string, error) {
+		if base == "" {
+			return "n/a", fmt.Errorf("sysadmin: PI runner URL empty")
+		}
+		payload, err := BuildRunnerPayload(ctx, svc)
+		if err != nil {
+			return "error", err
+		}
+		body, err := json.Marshal(payload)
+		if err != nil {
+			return "error", err
+		}
+		req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/admin/reload", bytes.NewReader(body))
+		if err != nil {
+			return "error", err
+		}
+		req.Header.Set("Content-Type", "application/json")
+		if token != "" {
+			req.Header.Set("Authorization", "Bearer "+token)
+		}
+		resp, err := client.Do(req)
+		if err != nil {
+			return "error", fmt.Errorf("sysadmin: runner reload: %w", err)
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode >= 300 {
+			return "error", fmt.Errorf("sysadmin: runner reload HTTP %d", resp.StatusCode)
+		}
+		return "ok", nil
+	}
+}
+
+// BuildRunnerPayload 从白名单解析值组装 reload 载荷。
+func BuildRunnerPayload(ctx context.Context, svc *Service) (RunnerApplyPayload, error) {
+	var out RunnerApplyPayload
+	if svc == nil {
+		return out, fmt.Errorf("sysadmin: nil service")
+	}
+	get := func(key string) string {
+		v, _ := svc.ResolveRawString(ctx, key)
+		return strings.TrimSpace(v)
+	}
+	out.Minimax.Endpoint = get("agent.minimax_endpoint_url")
+	out.Minimax.ModelID = get("agent.minimax_model_id")
+	out.Minimax.APIType = get("agent.minimax_api_type")
+	out.Minimax.APIKey = get("secrets.minimax_api_key")
+	out.PIModel = get("agent.pi_agent_model")
+	rawKeys := get("secrets.tavily_api_keys")
+	if rawKeys != "" {
+		for _, part := range strings.FieldsFunc(rawKeys, func(r rune) bool {
+			return r == ',' || r == ';' || r == '\n' || r == ' '
+		}) {
+			if part != "" {
+				out.TavilyKeys = append(out.TavilyKeys, part)
+			}
+		}
+	}
+	return out, nil
+}
diff --git a/internal/sysadmin/service.go b/internal/sysadmin/service.go
index da636643..59b2064b 100644
--- a/internal/sysadmin/service.go
+++ b/internal/sysadmin/service.go
@@ -23,6 +23,9 @@ type KBProber interface {
 	Online() bool
 }
 
+// ApplyFunc 将 agent/secrets 热更新推送到 PI runner;返回状态文案与错误。
+type ApplyFunc func(ctx context.Context) (status string, err error)
+
 // Options 装配根注入的依赖;全部为只读探针,聚合层不持有业务写路径。
 type Options struct {
 	Pool           *pgxpool.Pool
@@ -31,6 +34,7 @@ type Options struct {
 	KB             KBProber
 	TaskStoreReady func(ctx context.Context) bool
 	FeatureValues  func() map[string]string // 组合根解析后的功能开关生效值(key 为环境变量名)
+	ApplyFn        ApplyFunc                // 可选:保存 HotReload 键后推送 runner
 }
 
 // Service 系统管理台聚合服务。
@@ -41,6 +45,7 @@ type Service struct {
 	kb             KBProber
 	taskStoreReady func(ctx context.Context) bool
 	featureValues  func() map[string]string
+	applyFn        ApplyFunc
 	specs          []SettingSpec
 	startedAt      time.Time
 }
@@ -58,11 +63,27 @@ func NewService(cfg *config.Config, opts Options) *Service {
 		kb:             opts.KB,
 		taskStoreReady: opts.TaskStoreReady,
 		featureValues:  opts.FeatureValues,
+		applyFn:        opts.ApplyFn,
 		specs:          defaultSettingSpecs(),
 		startedAt:      time.Now(),
 	}
 }
 
+// SetApplyFn 运行期注入推送回调(组合根在 runner URL 就绪后设置)。
+func (s *Service) SetApplyFn(fn ApplyFunc) {
+	if s != nil {
+		s.applyFn = fn
+	}
+}
+
+// ApplyToRunner 主动推送当前 DB/env 解析值到 PI runner。
+func (s *Service) ApplyToRunner(ctx context.Context) (string, error) {
+	if s == nil || s.applyFn == nil {
+		return "n/a", nil
+	}
+	return s.applyFn(ctx)
+}
+
 // Overview 系统总览,字段对齐前端 camelCase 契约。
 type Overview struct {
 	Status         string `json:"status"`
diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go
index e1720862..cbdd528d 100644
--- a/internal/sysadmin/settings.go
+++ b/internal/sysadmin/settings.go
@@ -3,21 +3,25 @@ package sysadmin
 import (
 	"bytes"
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
 	"strconv"
+	"strings"
 	"time"
 )
 
 // 设置读写错误;HTTP 适配层据此映射状态码。
 var (
-	ErrUnknownSetting             = errors.New("sysadmin: unknown setting key")
-	ErrSettingReadOnly            = errors.New("sysadmin: setting is read-only")
-	ErrInvalidSettingValue        = errors.New("sysadmin: invalid setting value")
-	ErrStoreUnavailable           = errors.New("sysadmin: settings store unavailable")
-	ErrExpectedRevisionRequired   = errors.New("sysadmin: expected_revision required")
+	ErrUnknownSetting           = errors.New("sysadmin: unknown setting key")
+	ErrSettingReadOnly          = errors.New("sysadmin: setting is read-only")
+	ErrInvalidSettingValue      = errors.New("sysadmin: invalid setting value")
+	ErrStoreUnavailable         = errors.New("sysadmin: settings store unavailable")
+	ErrExpectedRevisionRequired = errors.New("sysadmin: expected_revision required")
+	ErrSecretUnchanged          = errors.New("sysadmin: secret empty means keep existing")
 )
 
 // 设置值类型。
@@ -36,6 +40,8 @@ type SettingSpec struct {
 	Default         any
 	Writable        bool
 	RequiresRestart bool
+	Secret          bool   // true 时 API 只回掩码,审计只存指纹
+	HotReload       bool   // true 时保存后可推送到 PI runner 热更新
 	EnvVar          string // 可选 env 中间层:DB > env > default
 	Description     string
 }
@@ -50,10 +56,16 @@ type Setting struct {
 	Default         any        `json:"default"`
 	Writable        bool       `json:"writable"`
 	RequiresRestart bool       `json:"requiresRestart"`
+	Secret          bool       `json:"secret,omitempty"`
+	HotReload       bool       `json:"hotReload,omitempty"`
+	HasValue        bool       `json:"hasValue,omitempty"`
+	Masked          string     `json:"masked,omitempty"`
 	Description     string     `json:"description"`
 	Revision        int64      `json:"revision"`
 	UpdatedAt       *time.Time `json:"updatedAt,omitempty"`
 	UpdatedBy       *int64     `json:"updatedBy,omitempty"`
+	ApplyStatus     string     `json:"applyStatus,omitempty"` // ok | pending | error | n/a
+	ApplyError      string     `json:"applyError,omitempty"`
 }
 
 // SettingUpdate 写入入参;仅版本化策略 key 要求 ExpectedRevision。
@@ -65,18 +77,12 @@ type SettingUpdate struct {
 	RequestID        string
 }
 
-// defaultSettingSpecs 一期白名单:先做机制,键空间可后续扩展。
+// defaultSettingSpecs 白名单:UI/业务策略 + agent/secrets(.env 类)。
 //
-// Wave 5 X-4: 除 SettingKeyAllowAuthorOnlyApproval 已接线外,下列 6 键暂无运行时
-// 消费方;决策 = 保留白名单待系统管理台二期接线(FEATURE-MATRIX「白名单项接消费方」),
-// 不从白名单删除。见 .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-4。
-// 预期消费方(二期):
-//   - ui.maintenance_notice → 站点顶部维护公告横幅
+// Wave 5 X-4: ui.* / chat.* / tasks.* / kb.* / notifications.* 部分键待消费方接线。
+// 预期消费方:
+//   - ui.maintenance_notice → 站点顶部维护公告横幅(P2 maintenance 接管)
 //   - ui.read_only_mode → 写操作中间件 / 前端写入口阻断
-//   - chat.default_session_role → 会话创建默认职责
-//   - tasks.default_auto_repair → 独立任务运行默认 auto_repair
-//   - kb.default_search_limit → 知识库检索默认 limit
-//   - notifications.stage_advance_reminder → 阶段就绪提醒 ticker 总开关
 func defaultSettingSpecs() []SettingSpec {
 	return []SettingSpec{
 		{Key: "ui.maintenance_notice", Label: "维护公告", Group: "ui", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, Description: "全站维护公告横幅文案;空字符串表示关闭"},
@@ -90,6 +96,17 @@ func defaultSettingSpecs() []SettingSpec {
 			Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false,
 			Description: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。",
 		},
+		// agent 组:模型与端点(可热推 runner)
+		{Key: "agent.minimax_endpoint_url", Label: "MiniMax Endpoint", Group: "agent", Type: SettingTypeString, Default: "https://api.minimaxi.com/v1", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_ENDPOINT_URL", Description: "MiniMax API 基址"},
+		{Key: "agent.minimax_model_id", Label: "MiniMax 模型 ID", Group: "agent", Type: SettingTypeString, Default: "MiniMax-M2.7-highspeed", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_MODEL_ID", Description: "默认模型 ID"},
+		{Key: "agent.minimax_api_type", Label: "MiniMax API 类型", Group: "agent", Type: SettingTypeString, Default: "openai-compatible", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_API_TYPE", Description: "openai-compatible 或原生类型"},
+		{Key: "agent.pi_agent_model", Label: "PI Agent 模型", Group: "agent", Type: SettingTypeString, Default: "MiniMax-M2.7-highspeed", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "PI_AGENT_MODEL", Description: "宿主侧 PI Agent 模型名"},
+		// secrets 组:密钥(掩码 + 热推或部分需重启)
+		{Key: "secrets.minimax_api_key", Label: "MiniMax API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, HotReload: true, Secret: true, EnvVar: "MINIMAX_API_KEY", Description: "MiniMax API 密钥;空保存表示保持原值"},
+		{Key: "secrets.tavily_api_keys", Label: "Tavily API Keys", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, HotReload: true, Secret: true, EnvVar: "TAVILY_API_KEYS", Description: "逗号分隔的 Tavily key 池;空保存表示保持原值"},
+		{Key: "secrets.embedding_api_key", Label: "Embedding API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "EMBEDDING_API_KEY", Description: "向量 Embedding 密钥(需重启 Go 宿主)"},
+		{Key: "secrets.reranker_api_key", Label: "Reranker API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "RERANKER_API_KEY", Description: "Reranker 密钥(需重启 Go 宿主)"},
+		{Key: "secrets.mineru_api_key", Label: "MinerU API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "MINERU_API_KEY", Description: "MinerU 文档解析密钥(需重启 Go 宿主)"},
 	}
 }
 
@@ -116,7 +133,6 @@ func (s *Service) ListSettings(ctx context.Context) ([]Setting, error) {
 }
 
 // UpdateSetting 校验并写入白名单设置,返回更新后的视图。
-// 兼容旧调用:非版本化 key 可走此入口;版本化策略 key 必须带 ExpectedRevision,请用 UpdateSettingEx。
 func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMessage, updatedBy int64) (Setting, error) {
 	return s.UpdateSettingEx(ctx, SettingUpdate{Key: key, Value: raw, UpdatedBy: updatedBy})
 }
@@ -144,25 +160,58 @@ func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Settin
 	if !spec.Writable {
 		return Setting{}, ErrSettingReadOnly
 	}
-	if err := validateSettingValue(spec.Type, in.Value); err != nil {
-		return Setting{}, err
-	}
 	if s.store == nil {
 		return Setting{}, ErrStoreUnavailable
 	}
+
+	raw := in.Value
+	if spec.Secret {
+		keep, err := secretKeepExisting(raw)
+		if err != nil {
+			return Setting{}, err
+		}
+		if keep {
+			return s.resolve(ctx, spec)
+		}
+	}
+
+	if err := validateSettingValue(spec.Type, raw); err != nil {
+		return Setting{}, err
+	}
+
 	if in.Key == SettingKeyAllowAuthorOnlyApproval {
 		if in.ExpectedRevision == nil {
 			return Setting{}, ErrExpectedRevisionRequired
 		}
-		if _, _, err := s.store.UpsertCAS(ctx, in.Key, in.Value, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil {
+		if _, _, err := s.store.UpsertCAS(ctx, in.Key, raw, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil {
 			return Setting{}, err
 		}
 	} else {
-		if _, _, err := s.store.Upsert(ctx, in.Key, in.Value, in.UpdatedBy); err != nil {
+		auditAfter := raw
+		if spec.Secret {
+			auditAfter = secretFingerprintJSON(raw)
+		}
+		if _, _, err := s.store.UpsertWithAudit(ctx, in.Key, raw, auditAfter, in.UpdatedBy, in.RequestID); err != nil {
 			return Setting{}, err
 		}
 	}
-	return s.resolve(ctx, spec)
+
+	setting, err := s.resolve(ctx, spec)
+	if err != nil {
+		return Setting{}, err
+	}
+	if spec.HotReload && s.applyFn != nil {
+		status, applyErr := s.applyFn(ctx)
+		setting.ApplyStatus = status
+		if applyErr != nil {
+			setting.ApplyError = applyErr.Error()
+		}
+	} else if spec.RequiresRestart {
+		setting.ApplyStatus = "pending_restart"
+	} else {
+		setting.ApplyStatus = "ok"
+	}
+	return setting, nil
 }
 
 // resolve 按 DB > env > default 解析单个设置的当前值。
@@ -176,6 +225,8 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error
 		Default:         spec.Default,
 		Writable:        spec.Writable,
 		RequiresRestart: spec.RequiresRestart,
+		Secret:          spec.Secret,
+		HotReload:       spec.HotReload,
 		Description:     spec.Description,
 	}
 	if spec.EnvVar != "" {
@@ -186,12 +237,12 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error
 		}
 	}
 	if s.store == nil {
-		return setting, nil
+		return finalizeSecretView(setting, spec), nil
 	}
 	raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key)
 	if err != nil {
 		if errors.Is(err, ErrSettingNotFound) {
-			return setting, nil
+			return finalizeSecretView(setting, spec), nil
 		}
 		return Setting{}, err
 	}
@@ -203,7 +254,109 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error
 	setting.Revision = revision
 	setting.UpdatedBy = updatedBy
 	setting.UpdatedAt = updatedAt
-	return setting, nil
+	return finalizeSecretView(setting, spec), nil
+}
+
+// ResolveRawString 返回未掩码的字符串值(供 runner 推送);secret 也返回明文。
+func (s *Service) ResolveRawString(ctx context.Context, key string) (string, error) {
+	spec, ok := s.findSpec(key)
+	if !ok {
+		return "", ErrUnknownSetting
+	}
+	if s.store != nil {
+		raw, _, _, _, err := s.store.Get(ctx, key)
+		if err == nil {
+			var v string
+			if err := json.Unmarshal(raw, &v); err == nil {
+				return v, nil
+			}
+		} else if !errors.Is(err, ErrSettingNotFound) {
+			return "", err
+		}
+	}
+	if spec.EnvVar != "" {
+		if raw, set := os.LookupEnv(spec.EnvVar); set {
+			return raw, nil
+		}
+	}
+	if s, ok := spec.Default.(string); ok {
+		return s, nil
+	}
+	return "", nil
+}
+
+func finalizeSecretView(setting Setting, spec SettingSpec) Setting {
+	if !spec.Secret {
+		return setting
+	}
+	plain := ""
+	switch v := setting.Value.(type) {
+	case string:
+		plain = v
+	case nil:
+		plain = ""
+	default:
+		b, _ := json.Marshal(v)
+		plain = string(b)
+	}
+	setting.HasValue = strings.TrimSpace(plain) != ""
+	setting.Masked = MaskSecret(plain)
+	setting.Value = ""
+	return setting
+}
+
+// MaskSecret 展示前缀/后缀,中间打码。
+func MaskSecret(plain string) string {
+	plain = strings.TrimSpace(plain)
+	if plain == "" {
+		return ""
+	}
+	// 多 key 池:分别掩码后拼接
+	if strings.ContainsAny(plain, ",;\n") {
+		parts := strings.FieldsFunc(plain, func(r rune) bool {
+			return r == ',' || r == ';' || r == '\n' || r == ' '
+		})
+		masked := make([]string, 0, len(parts))
+		for _, p := range parts {
+			if p == "" {
+				continue
+			}
+			masked = append(masked, maskOne(p))
+		}
+		return strings.Join(masked, ",")
+	}
+	return maskOne(plain)
+}
+
+func maskOne(s string) string {
+	if len(s) < 8 {
+		return "****"
+	}
+	return s[:4] + "..." + s[len(s)-4:]
+}
+
+func secretKeepExisting(raw json.RawMessage) (bool, error) {
+	trimmed := bytes.TrimSpace(raw)
+	if len(trimmed) == 0 || string(trimmed) == "null" {
+		return true, nil
+	}
+	var v string
+	if err := json.Unmarshal(raw, &v); err != nil {
+		return false, ErrInvalidSettingValue
+	}
+	return strings.TrimSpace(v) == "", nil
+}
+
+func secretFingerprintJSON(raw json.RawMessage) json.RawMessage {
+	var v string
+	if err := json.Unmarshal(raw, &v); err != nil {
+		sum := sha256.Sum256(raw)
+		out, _ := json.Marshal(map[string]string{"sha256": hex.EncodeToString(sum[:])})
+		return out
+	}
+	sum := sha256.Sum256([]byte(v))
+	out, _ := json.Marshal(map[string]string{"sha256": hex.EncodeToString(sum[:])})
+	return out
 }
 
 // validateSettingValue 按声明类型校验 JSON 值。
@@ -228,7 +381,6 @@ func validateSettingValue(valueType string, raw json.RawMessage) error {
 		if err := dec.Decode(&v); err != nil {
 			return ErrInvalidSettingValue
 		}
-		// 拒绝尾随垃圾(如 truefalse)
 		if dec.More() {
 			return ErrInvalidSettingValue
 		}
diff --git a/internal/sysadmin/settings_secret_test.go b/internal/sysadmin/settings_secret_test.go
new file mode 100644
index 00000000..85309834
--- /dev/null
+++ b/internal/sysadmin/settings_secret_test.go
@@ -0,0 +1,94 @@
+package sysadmin
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+)
+
+func TestMaskSecret(t *testing.T) {
+	if got := MaskSecret(""); got != "" {
+		t.Fatalf("empty: %q", got)
+	}
+	if got := MaskSecret("short"); got != "****" {
+		t.Fatalf("short: %q", got)
+	}
+	got := MaskSecret("tvly-prod-ABCDEFGHijklmnop")
+	if got != "tvly...mnop" {
+		t.Fatalf("single: %q", got)
+	}
+	got = MaskSecret("tvly-aaaa1111,tvly-bbbb2222")
+	if got != "tvly...1111,tvly...2222" {
+		t.Fatalf("pool: %q", got)
+	}
+}
+
+func TestSecretKeepAndFingerprint(t *testing.T) {
+	keep, err := secretKeepExisting(json.RawMessage(`""`))
+	if err != nil || !keep {
+		t.Fatalf("empty keep: keep=%v err=%v", keep, err)
+	}
+	keep, err = secretKeepExisting(json.RawMessage(`"new-key"`))
+	if err != nil || keep {
+		t.Fatalf("new value should not keep: keep=%v err=%v", keep, err)
+	}
+	fp := secretFingerprintJSON(json.RawMessage(`"secret-value"`))
+	var m map[string]string
+	if err := json.Unmarshal(fp, &m); err != nil || m["sha256"] == "" {
+		t.Fatalf("fingerprint: %s err=%v", string(fp), err)
+	}
+	if stringsContains(string(fp), "secret-value") {
+		t.Fatal("fingerprint leaked plaintext")
+	}
+}
+
+func TestSecretSettingMaskedOnList(t *testing.T) {
+	store := newMemStore()
+	svc := NewService(nil, Options{Store: store})
+	raw, _ := json.Marshal("tvly-prod-REALKEYVALUE99")
+	if _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
+		Key: "secrets.tavily_api_keys", Value: raw, UpdatedBy: 1,
+	}); err != nil {
+		t.Fatal(err)
+	}
+	list, err := svc.ListSettings(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	var found *Setting
+	for i := range list {
+		if list[i].Key == "secrets.tavily_api_keys" {
+			found = &list[i]
+			break
+		}
+	}
+	if found == nil {
+		t.Fatal("setting not found")
+	}
+	if found.Value != "" {
+		t.Fatalf("secret value leaked: %#v", found.Value)
+	}
+	if !found.HasValue || found.Masked == "" || stringsContains(found.Masked, "REALKEY") {
+		t.Fatalf("mask broken: %+v", found)
+	}
+	// audit should be fingerprint
+	if len(store.audits) == 0 {
+		t.Fatal("expected audit")
+	}
+	if stringsContains(string(store.audits[len(store.audits)-1].after), "REALKEY") {
+		t.Fatalf("audit leaked: %s", store.audits[len(store.audits)-1].after)
+	}
+}
+
+func stringsContains(s, sub string) bool {
+	return len(sub) == 0 || (len(s) >= len(sub) && (s == sub || len(s) > 0 && containsFold(s, sub)))
+}
+
+func containsFold(s, sub string) bool {
+	for i := 0; i+len(sub) <= len(s); i++ {
+		if s[i:i+len(sub)] == sub {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/sysadmin/settings_test.go b/internal/sysadmin/settings_test.go
index 09cc8d2c..441728a8 100644
--- a/internal/sysadmin/settings_test.go
+++ b/internal/sysadmin/settings_test.go
@@ -50,6 +50,10 @@ func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64,
 }
 
 func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) {
+	return m.UpsertWithAudit(context.Background(), key, value, value, updatedBy, "")
+}
+
+func (m *memStore) UpsertWithAudit(_ context.Context, key string, value, auditAfter json.RawMessage, updatedBy int64, requestID string) (time.Time, int64, error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	at := time.Now()
@@ -60,13 +64,13 @@ func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage,
 		value:     after,
 		updatedBy: &updatedBy,
 		updatedAt: at,
-		revision:  prev.revision,
+		revision:  prev.revision + 1,
 	}
 	m.audits = append(m.audits, memAudit{
-		key: key, before: before, after: after,
-		beforeRev: prev.revision, afterRev: prev.revision, actor: updatedBy,
+		key: key, before: before, after: append(json.RawMessage(nil), auditAfter...),
+		beforeRev: prev.revision, afterRev: prev.revision + 1, actor: updatedBy, requestID: requestID,
 	})
-	return at, prev.revision, nil
+	return at, prev.revision + 1, nil
 }
 
 func (m *memStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) {
diff --git a/internal/sysadmin/store.go b/internal/sysadmin/store.go
index 8f685007..43f32522 100644
--- a/internal/sysadmin/store.go
+++ b/internal/sysadmin/store.go
@@ -20,6 +20,8 @@ var ErrSettingRevisionConflict = errors.New("sysadmin: setting revision conflict
 type SettingsStore interface {
 	Get(ctx context.Context, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, revision int64, err error)
 	Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (updatedAt time.Time, revision int64, err error)
+	// UpsertWithAudit 写入明文 value,审计表写入 auditAfter(secret 键应为指纹 JSON)。
+	UpsertWithAudit(ctx context.Context, key string, value, auditAfter json.RawMessage, updatedBy int64, requestID string) (updatedAt time.Time, revision int64, err error)
 	// UpsertCAS 在同一事务内校验 expectedRevision、写入新值并追加策略审计;冲突返回 ErrSettingRevisionConflict。
 	UpsertCAS(ctx context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (updatedAt time.Time, revision int64, err error)
 	// GetForUpdate 在调用方事务内锁定目标行(FOR UPDATE),供激活路径与关闭串行化。
@@ -67,16 +69,48 @@ func (s *pgSettingsStore) Get(ctx context.Context, key string) (json.RawMessage,
 }
 
 func (s *pgSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) {
+	return s.UpsertWithAudit(ctx, key, value, value, updatedBy, "")
+}
+
+func (s *pgSettingsStore) UpsertWithAudit(ctx context.Context, key string, value, auditAfter json.RawMessage, updatedBy int64, requestID string) (time.Time, int64, error) {
+	tx, err := s.pool.Begin(ctx)
+	if err != nil {
+		return time.Time{}, 0, err
+	}
+	defer tx.Rollback(ctx)
+
+	var beforeRaw []byte
+	_ = tx.QueryRow(ctx, `SELECT value FROM system_settings WHERE key = $1`, key).Scan(&beforeRaw)
+
 	var updatedAt time.Time
 	var revision int64
-	err := s.pool.QueryRow(ctx, `
+	err = tx.QueryRow(ctx, `
 		INSERT INTO system_settings (key, value, updated_by, updated_at, revision)
 		VALUES ($1, $2, $3, now(), 0)
 		ON CONFLICT (key) DO UPDATE
-		SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = now()
+		SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = now(),
+		    revision = system_settings.revision + 1
 		RETURNING updated_at, revision
 	`, key, []byte(value), updatedBy).Scan(&updatedAt, &revision)
-	return updatedAt, revision, err
+	if err != nil {
+		return time.Time{}, 0, err
+	}
+
+	var reqID any
+	if requestID != "" {
+		reqID = requestID
+	}
+	_, err = tx.Exec(ctx, `
+		INSERT INTO system_settings_audits (setting_key, before_value, after_value, actor_user_id, request_id)
+		VALUES ($1, $2::jsonb, $3::jsonb, $4, $5)
+	`, key, beforeRaw, []byte(auditAfter), updatedBy, reqID)
+	if err != nil {
+		return time.Time{}, 0, err
+	}
+	if err := tx.Commit(ctx); err != nil {
+		return time.Time{}, 0, err
+	}
+	return updatedAt, revision, nil
 }
 
 func (s *pgSettingsStore) UpsertCAS(ctx context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index e2776d2c..681d1bfa 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -40,6 +40,7 @@ import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
 import { OnDemand } from './components/lazy/OnDemand';
 import { fetchProjectSettings, reassignDocTaskDuties } from './api/projectSettings';
 import { LoginView } from './components/auth/LoginView';
+import { MaintenanceBanner } from './components/maintenance/MaintenanceBanner';
 import { authClient } from './api/auth';
 import { LoginResult, SystemAuthConfig } from './types/auth';
 import { markdownToBlockAST } from './engine/markdownToBlock';
@@ -421,6 +422,7 @@ export default function App() {
 
   const workflowBanner = (
     <>
+      
       {(projectSyncNotice || sessionSyncNotice) && (
         
{ - const switched = adoptWorkbenchUser(newSession.user.id); - if (switched) { - setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES)); - setSessions(loadSessions()); - setMessages([]); - setDocCache({}); - setProjects([]); - setActiveDocId(''); - setActiveSessionId(loadActiveSessionId('')); - } - setSession(newSession); - setSessionValidated(true); - setCapabilities(newSession.capabilities ?? []); - if (newSession.env) setSystemEnv(newSession.env); - const match = allUsers.find((u) => u.id === newSession.user.id); - setCurrentUser(mergeSessionUser(newSession.user, match)); - }} - systemConfig={systemAuthConfig} - env={systemEnv} - /> +
+ +
+ { + const switched = adoptWorkbenchUser(newSession.user.id); + if (switched) { + setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES)); + setSessions(loadSessions()); + setMessages([]); + setDocCache({}); + setProjects([]); + setActiveDocId(''); + setActiveSessionId(loadActiveSessionId('')); + } + setSession(newSession); + setSessionValidated(true); + setCapabilities(newSession.capabilities ?? []); + if (newSession.env) setSystemEnv(newSession.env); + const match = allUsers.find((u) => u.id === newSession.user.id); + setCurrentUser(mergeSessionUser(newSession.user, match)); + }} + systemConfig={systemAuthConfig} + env={systemEnv} + /> +
+
); } diff --git a/web/src/api/maintenance.ts b/web/src/api/maintenance.ts new file mode 100644 index 00000000..8db78d6a --- /dev/null +++ b/web/src/api/maintenance.ts @@ -0,0 +1,54 @@ +import { apiFetch } from './client'; +import type { MaintenanceStatus, MaintenanceWindow } from '../types/maintenance'; + +async function readError(resp: Response, fallback: string): Promise { + let err: Error; + try { + const text = await resp.text(); + err = text ? new Error(text.slice(0, 200)) : new Error(`${fallback} (HTTP ${resp.status})`); + } catch { + err = new Error(`${fallback} (HTTP ${resp.status})`); + } + (err as Error & { status?: number }).status = resp.status; + return err; +} + +export async function fetchMaintenanceStatus(): Promise { + const resp = await apiFetch('/api/v1/maintenance/status', undefined, { auth: false }); + if (!resp.ok) throw await readError(resp, '获取维护状态失败'); + return resp.json() as Promise; +} + +export async function fetchMaintenanceWindows(): Promise { + const resp = await apiFetch('/api/v1/admin/maintenance'); + if (!resp.ok) throw await readError(resp, '获取维护计划失败'); + const data = (await resp.json()) as { windows?: MaintenanceWindow[] }; + return data.windows ?? []; +} + +export async function scheduleMaintenance(input: { + start: string; + hours: number; + message: string; +}): Promise { + const start = new Date(input.start); + const end = new Date(start.getTime() + input.hours * 3600 * 1000); + const resp = await apiFetch('/api/v1/admin/maintenance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start: start.toISOString(), + end: end.toISOString(), + hours: input.hours, + message: input.message, + }), + }); + if (!resp.ok) throw await readError(resp, '创建维护计划失败'); + return resp.json() as Promise; +} + +export async function cancelMaintenance(id: number): Promise { + const resp = await apiFetch(`/api/v1/admin/maintenance/${id}/cancel`, { method: 'POST' }); + if (!resp.ok) throw await readError(resp, '取消维护计划失败'); + return resp.json() as Promise; +} diff --git a/web/src/components/admin/console/AdminConsoleView.tsx b/web/src/components/admin/console/AdminConsoleView.tsx index c32363b2..09459835 100644 --- a/web/src/components/admin/console/AdminConsoleView.tsx +++ b/web/src/components/admin/console/AdminConsoleView.tsx @@ -5,19 +5,23 @@ import { Database, FileText, GitBranch, + KeyRound, Shield, ShieldAlert, ShieldX, Sliders, ToggleLeft, Users, + Wrench, } from 'lucide-react'; import { fetchSystemOverview } from '../../../api/systemAdmin'; import { UserProfile } from '../../../types/docgov'; import { RoleDefinition } from '../../../types/rbac'; import { DocsSection } from './sections/DocsSection'; +import { EnvSettingsSection } from './sections/EnvSettingsSection'; import { FeaturesSection } from './sections/FeaturesSection'; import { KbSection } from './sections/KbSection'; +import { MaintenanceSection } from './sections/MaintenanceSection'; import { OverviewSection } from './sections/OverviewSection'; import { RolesSection } from './sections/RolesSection'; import { SettingsSection } from './sections/SettingsSection'; @@ -39,6 +43,8 @@ type SectionKey = | 'kb' | 'features' | 'settings' + | 'env' + | 'maintenance' | 'workflows' | 'roles' | 'users' @@ -49,6 +55,8 @@ const NAV_ITEMS: Array<{ key: SectionKey; label: string; icon: React.ReactNode } { key: 'kb', label: '知识库连接', icon: }, { key: 'features', label: '特性功能开关', icon: }, { key: 'settings', label: '系统设置白名单', icon: }, + { key: 'env', label: '模型与密钥', icon: }, + { key: 'maintenance', label: '维护计划', icon: }, { key: 'workflows', label: '工作流定义', icon: }, { key: 'roles', label: 'RBAC 角色权限', icon: }, { key: 'users', label: '用户与准入治理', icon: }, @@ -95,6 +103,10 @@ export const AdminConsoleView: React.FC = ({ return ; case 'settings': return ; + case 'env': + return ; + case 'maintenance': + return ; case 'workflows': return ; case 'roles': diff --git a/web/src/components/admin/console/sections/EnvSettingsSection.tsx b/web/src/components/admin/console/sections/EnvSettingsSection.tsx new file mode 100644 index 00000000..d3194ef4 --- /dev/null +++ b/web/src/components/admin/console/sections/EnvSettingsSection.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { AlertCircle, CheckCircle2, KeyRound } from 'lucide-react'; +import { fetchSystemSettings, updateSystemSetting } from '../../../../api/systemAdmin'; +import type { SystemConfigValue, SystemSetting } from '../../../../types/systemAdmin'; +import { + RestartBadge, + SectionError, + SectionHeader, + SectionLoading, + sectionCardStyle, +} from './sectionCommon'; + +const ENV_GROUPS = new Set(['agent', 'secrets']); + +function EffectBadge({ setting }: { setting: SystemSetting }) { + if (setting.requiresRestart) return ; + if (setting.hotReload) { + return ( + + 可热生效 + + ); + } + return null; +} + +export const EnvSettingsSection: React.FC = () => { + const [settings, setSettings] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [savingKey, setSavingKey] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tip, setTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const showTip = (type: 'success' | 'error', msg: string) => { + setTip({ type, msg }); + setTimeout(() => setTip(null), 4000); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = (await fetchSystemSettings()).filter((s) => ENV_GROUPS.has(s.group)); + setSettings(list); + setDrafts( + Object.fromEntries( + list.map((s) => [s.key, s.secret ? '' : s.value]), + ), + ); + } catch (err) { + setError(err instanceof Error ? err.message : '获取环境设置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async (setting: SystemSetting) => { + setSavingKey(setting.key); + try { + const draft = drafts[setting.key]; + if (setting.secret && String(draft ?? '').trim() === '') { + showTip('error', '密钥为空表示保持原值;请填写新值后再保存'); + return; + } + const updated = await updateSystemSetting( + setting.key, + draft, + typeof setting.revision === 'number' ? setting.revision : undefined, + ); + setSettings((prev) => prev.map((s) => (s.key === updated.key ? updated : s))); + setDrafts((prev) => ({ ...prev, [updated.key]: updated.secret ? '' : updated.value })); + const applyNote = + updated.applyStatus === 'ok' + ? '已热推送到 PI runner' + : updated.applyStatus === 'error' + ? `已保存,但推送失败:${updated.applyError || 'unknown'}` + : updated.requiresRestart + ? '已保存,需重启 Go 宿主后生效' + : '已保存'; + showTip(updated.applyStatus === 'error' ? 'error' : 'success', `【${updated.label || updated.key}】${applyNote}`); + } catch (err) { + const status = (err as Error & { status?: number }).status; + if (status === 409) { + showTip('error', '设置版本冲突,请刷新后重试'); + void load(); + } else { + showTip('error', err instanceof Error ? err.message : '保存设置失败'); + } + } finally { + setSavingKey(null); + } + }; + + if (loading) return ; + if (error) return void load()} />; + + const groups: Array<{ group: string; items: SystemSetting[] }> = []; + for (const s of settings) { + const g = s.group || '其他'; + const found = groups.find((x) => x.group === g); + if (found) found.items.push(s); + else groups.push({ group: g, items: [s] }); + } + + return ( +
+
+ + {tip && ( + + {tip.type === 'success' ? : } + {tip.msg} + + )} +
+ + {groups.length === 0 && } + + {groups.map((g) => ( +
+
+ {g.group === 'agent' ? '模型与端点' : g.group === 'secrets' ? '密钥' : g.group} +
+ {g.items.map((s) => { + const draft = drafts[s.key]; + const dirty = s.writable && String(draft ?? '') !== '' && (!s.secret || String(draft) !== ''); + const secretDirty = s.secret && String(draft ?? '').trim() !== ''; + const plainDirty = !s.secret && draft !== s.value; + const canSave = s.writable && (secretDirty || plainDirty); + return ( +
+
+
+ + {s.label || s.key} + + {s.secret && } + +
+ {s.description && ( +
+ {s.description} +
+ )} +
+ {s.key} + {s.secret && s.hasValue ? ` · 当前 ${s.masked || '****'}` : ''} + {s.secret && !s.hasValue ? ' · 未配置' : ''} +
+
+
+ setDrafts((prev) => ({ ...prev, [s.key]: e.target.value }))} + style={{ + width: '260px', + padding: '6px 10px', + borderRadius: '6px', + backgroundColor: 'var(--bg-app)', + border: '1px solid var(--border)', + color: 'var(--text-strong)', + fontSize: '12px', + boxSizing: 'border-box', + }} + /> + {s.writable && ( + + )} +
+
+ ); + })} +
+ ))} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/MaintenanceSection.tsx b/web/src/components/admin/console/sections/MaintenanceSection.tsx new file mode 100644 index 00000000..5f80c858 --- /dev/null +++ b/web/src/components/admin/console/sections/MaintenanceSection.tsx @@ -0,0 +1,186 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { + cancelMaintenance, + fetchMaintenanceWindows, + scheduleMaintenance, +} from '../../../../api/maintenance'; +import type { MaintenanceWindow } from '../../../../types/maintenance'; +import { + SectionError, + SectionHeader, + SectionLoading, + sectionCardStyle, +} from './sectionCommon'; + +function toLocalInputValue(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export const MaintenanceSection: React.FC = () => { + const [windows, setWindows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tip, setTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + const [start, setStart] = useState(() => toLocalInputValue(new Date(Date.now() + 3600_000))); + const [hours, setHours] = useState(2); + const [message, setMessage] = useState('系统将进行维护升级,期间服务暂时不可用。'); + const [saving, setSaving] = useState(false); + + const showTip = (type: 'success' | 'error', msg: string) => { + setTip({ type, msg }); + setTimeout(() => setTip(null), 4000); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setWindows(await fetchMaintenanceWindows()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取维护计划失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSchedule = async () => { + setSaving(true); + try { + await scheduleMaintenance({ start, hours, message }); + showTip('success', '维护计划已创建;到期将自动切换到公告页'); + void load(); + } catch (err) { + showTip('error', err instanceof Error ? err.message : '创建失败'); + } finally { + setSaving(false); + } + }; + + const handleCancel = async (id: number) => { + try { + await cancelMaintenance(id); + showTip('success', '已取消维护计划'); + void load(); + } catch (err) { + showTip('error', err instanceof Error ? err.message : '取消失败'); + } + }; + + if (loading) return ; + if (error) return void load()} />; + + const open = windows.find((w) => w.status === 'scheduled' || w.status === 'active'); + + return ( +
+
+ + {tip && ( + + {tip.type === 'success' ? : } + {tip.msg} + + )} +
+ + {open && ( +
+
+ 当前:{open.status === 'active' ? '维护进行中' : '已计划'} +
+
+ {new Date(open.scheduledStart).toLocaleString('zh-CN')} — {new Date(open.scheduledEnd).toLocaleString('zh-CN')} +
+ {open.message} +
+ {open.status === 'scheduled' && ( + + )} + {open.status === 'active' && ( +
+ 公告进程已接管端口。请在公告页 /admin 使用 data/*/maintenance-token 中的 token 启动新服务。 +
+ )} +
+ )} + +
+
新建维护计划
+ + +