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}}
+
+
`))
+
+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..e4df851c
--- /dev/null
+++ b/internal/handoff/handoff_e2e_test.go
@@ -0,0 +1,161 @@
+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")
+
+ // stub must live inside module for internal import
+ 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"
+ "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) }()
+ 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 启动新服务。
+
+ )}
+
+ )}
+
+
+
新建维护计划
+
+
+
+
+
+
+
最近记录
+ {windows.length === 0 &&
}
+ {windows.map((w) => (
+
+ #{w.id} · {w.status} · {new Date(w.scheduledStart).toLocaleString('zh-CN')} → {new Date(w.scheduledEnd).toLocaleString('zh-CN')}
+
{w.message}
+
+ ))}
+
+ );
+};
diff --git a/web/src/components/admin/console/sections/SettingsSection.tsx b/web/src/components/admin/console/sections/SettingsSection.tsx
index d9d4fc92..cb704912 100644
--- a/web/src/components/admin/console/sections/SettingsSection.tsx
+++ b/web/src/components/admin/console/sections/SettingsSection.tsx
@@ -32,7 +32,7 @@ export const SettingsSection: React.FC = () => {
setLoading(true);
setError(null);
try {
- const list = await fetchSystemSettings();
+ const list = (await fetchSystemSettings()).filter((s) => s.group !== 'agent' && s.group !== 'secrets');
setSettings(list);
setDrafts(Object.fromEntries(list.map((s) => [s.key, s.value])));
} catch (err) {
diff --git a/web/src/components/maintenance/MaintenanceBanner.tsx b/web/src/components/maintenance/MaintenanceBanner.tsx
new file mode 100644
index 00000000..63d0d223
--- /dev/null
+++ b/web/src/components/maintenance/MaintenanceBanner.tsx
@@ -0,0 +1,97 @@
+import React, { useEffect, useState } from 'react';
+import { fetchMaintenanceStatus } from '../../api/maintenance';
+import type { MaintenanceStatus } from '../../types/maintenance';
+
+function formatWhen(iso?: string): string {
+ if (!iso) return '';
+ try {
+ return new Date(iso).toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ } catch {
+ return iso;
+ }
+}
+
+/** 预告 / 进行中维护横幅(登录页与工作台共用) */
+export const MaintenanceBanner: React.FC<{ pollMs?: number }> = ({ pollMs = 45000 }) => {
+ const [status, setStatus] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const s = await fetchMaintenanceStatus();
+ if (!cancelled) setStatus(s);
+ } catch {
+ if (!cancelled) setStatus(null);
+ }
+ };
+ void load();
+ const t = setInterval(() => void load(), pollMs);
+ return () => {
+ cancelled = true;
+ clearInterval(t);
+ };
+ }, [pollMs]);
+
+ if (!status || (!status.scheduled && !status.active)) return null;
+
+ const hours = status.expectedHours ? status.expectedHours.toFixed(1) : '?';
+ const text = status.active
+ ? `系统维护升级中:${status.message || '请稍后再试'}`
+ : `系统将于 ${formatWhen(status.scheduledStart)} 起进行维护,预计 ${hours} 小时。${status.message ? ` ${status.message}` : ''}`;
+
+ return (
+
+ {text}
+
+ );
+};
+
+/** 非管理员在 active 时全屏提示(主服务尚未退出前的过渡) */
+export const MaintenanceOverlay: React.FC<{ status: MaintenanceStatus; onDismissAdmin?: () => void }> = ({
+ status,
+}) => {
+ if (!status.active) return null;
+ return (
+
+
+
系统维护升级中
+
{status.message || '请稍后再访问。'}
+ {status.scheduledEnd && (
+
+ 预计恢复:{formatWhen(status.scheduledEnd)}
+
+ )}
+
+
+ );
+};
diff --git a/web/src/types/maintenance.ts b/web/src/types/maintenance.ts
new file mode 100644
index 00000000..883cb3d4
--- /dev/null
+++ b/web/src/types/maintenance.ts
@@ -0,0 +1,26 @@
+/**
+ * 维护窗口公开/管理契约。
+ */
+export interface MaintenanceStatus {
+ active: boolean;
+ scheduled: boolean;
+ message: string;
+ scheduledStart?: string;
+ scheduledEnd?: string;
+ expectedHours?: number;
+ windowId?: number;
+ status: string;
+}
+
+export interface MaintenanceWindow {
+ id: number;
+ scheduledStart: string;
+ scheduledEnd: string;
+ message: string;
+ status: string;
+ createdBy?: number;
+ createdAt: string;
+ updatedAt: string;
+ activatedAt?: string;
+ endedAt?: string;
+}
diff --git a/web/src/types/systemAdmin.ts b/web/src/types/systemAdmin.ts
index 061f078c..33854ee5 100644
--- a/web/src/types/systemAdmin.ts
+++ b/web/src/types/systemAdmin.ts
@@ -57,9 +57,15 @@ export interface SystemSetting {
default: SystemConfigValue;
writable: boolean;
requiresRestart: boolean;
+ secret?: boolean;
+ hotReload?: boolean;
+ hasValue?: boolean;
+ masked?: string;
description: string;
/** CAS revision for versioned settings (e.g. workflow.allow_author_only_approval). */
revision?: number;
- updatedAt: string;
- updatedBy: string;
+ updatedAt?: string;
+ updatedBy?: string | number;
+ applyStatus?: string;
+ applyError?: string;
}