merge: candidate admin-settings-maint-b-2026-09-25 into dev

This commit is contained in:
weichen
2026-09-24 23:24:54 +00:00
34 changed files with 2844 additions and 76 deletions
@@ -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 |
+26
View File
@@ -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:
@@ -0,0 +1,88 @@
# 管理员设置页与维护公告系统 测试报告
## 一、 测试背景 (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 推送。
## 八、 集成备注 (Integration Note)
- 首次 `queue enqueue` 因 integrate worktree 瞬时 DIRTY_TREE 失败;清理后以本注记提交新 tip 重入队。
@@ -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
+182
View File
@@ -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(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>系统维护中</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(160deg,#0f172a,#1e293b);color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{max-width:560px;padding:40px 36px;border-radius:16px;background:rgba(15,23,42,.72);border:1px solid rgba(148,163,184,.25);box-shadow:0 20px 60px rgba(0,0,0,.35)}
h1{margin:0 0 12px;font-size:28px;letter-spacing:.02em}
p{margin:8px 0;line-height:1.6;color:#cbd5e1}
.meta{margin-top:20px;font-size:14px;color:#94a3b8}
a{color:#38bdf8}
</style></head><body>
<div class="card">
<h1>系统维护升级中</h1>
<p>{{.Message}}</p>
<div class="meta">
{{if not .Start.IsZero}}预计开始:{{.Start.Local.Format "2006年01月02日 15:04"}}<br/>{{end}}
{{if not .End.IsZero}}预计结束:{{.End.Local.Format "2006年01月02日 15:04"}}(约 {{printf "%.1f" .Hours}} 小时)<br/>{{end}}
管理员请前往 <a href="/admin">维护控制台</a> 完成上线。
</div>
</div></body></html>`))
var adminTmpl = template.Must(template.New("admin").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"/><title>维护管理</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;padding:40px}
input,button{padding:10px 14px;border-radius:8px;border:1px solid #334155;background:#1e293b;color:#e2e8f0}
button{cursor:pointer;background:#0369a1;border:none;margin-left:8px}
#msg{margin-top:16px;color:#94a3b8}
</style></head><body>
<h1>维护结束后上线</h1>
<p>替换服务端二进制后,输入激活时展示的管理员 token,启动新服务并下线本公告页。</p>
<input id="token" type="password" placeholder="维护管理员 Token" style="width:360px"/>
<button onclick="start()">启动服务并下线公告</button>
<pre id="msg"></pre>
<script>
async function start(){
const token=document.getElementById('token').value.trim();
const msg=document.getElementById('msg');
msg.textContent='请求中…';
try{
const r=await fetch('/api/v1/maintenance/admin/start',{method:'POST',headers:{'Authorization':'Bearer '+token}});
const t=await r.text();
msg.textContent=r.status+' '+t;
}catch(e){msg.textContent=String(e)}
}
</script></body></html>`))
+98 -6
View File
@@ -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<string, import('node:child_process').ChildProcess>} */
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 {
+82
View File
@@ -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 });
}
@@ -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;
+17
View File
@@ -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})
+127
View File
@@ -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
}
+161
View File
@@ -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")
}
}
+47
View File
@@ -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)
}
}
+180
View File
@@ -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)
}
}
+124
View File
@@ -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")
}
}
+195
View File
@@ -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
}
+14 -5
View File
@@ -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) {
+2 -1
View File
@@ -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" {
+238
View File
@@ -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
}
+57 -6
View File
@@ -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
+90
View File
@@ -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
}
+21
View File
@@ -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"`
+178 -26
View File
@@ -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
}
+94
View File
@@ -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
}
+8 -4
View File
@@ -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) {
+37 -3
View File
@@ -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) {
+29 -22
View File
@@ -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 = (
<>
<MaintenanceBanner />
{(projectSyncNotice || sessionSyncNotice) && (
<div
style={{
@@ -1518,28 +1520,33 @@ export default function App() {
if (!session) {
return (
<LoginView
onLoginSuccess={(newSession) => {
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}
/>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<MaintenanceBanner />
<div style={{ flex: 1 }}>
<LoginView
onLoginSuccess={(newSession) => {
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}
/>
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { apiFetch } from './client';
import type { MaintenanceStatus, MaintenanceWindow } from '../types/maintenance';
async function readError(resp: Response, fallback: string): Promise<Error> {
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<MaintenanceStatus> {
const resp = await apiFetch('/api/v1/maintenance/status', undefined, { auth: false });
if (!resp.ok) throw await readError(resp, '获取维护状态失败');
return resp.json() as Promise<MaintenanceStatus>;
}
export async function fetchMaintenanceWindows(): Promise<MaintenanceWindow[]> {
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<MaintenanceWindow> {
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<MaintenanceWindow>;
}
export async function cancelMaintenance(id: number): Promise<MaintenanceWindow> {
const resp = await apiFetch(`/api/v1/admin/maintenance/${id}/cancel`, { method: 'POST' });
if (!resp.ok) throw await readError(resp, '取消维护计划失败');
return resp.json() as Promise<MaintenanceWindow>;
}
@@ -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: <Database size={16} /> },
{ key: 'features', label: '特性功能开关', icon: <ToggleLeft size={16} /> },
{ key: 'settings', label: '系统设置白名单', icon: <Sliders size={16} /> },
{ key: 'env', label: '模型与密钥', icon: <KeyRound size={16} /> },
{ key: 'maintenance', label: '维护计划', icon: <Wrench size={16} /> },
{ key: 'workflows', label: '工作流定义', icon: <GitBranch size={16} /> },
{ key: 'roles', label: 'RBAC 角色权限', icon: <Shield size={16} /> },
{ key: 'users', label: '用户与准入治理', icon: <Users size={16} /> },
@@ -95,6 +103,10 @@ export const AdminConsoleView: React.FC<AdminConsoleViewProps> = ({
return <FeaturesSection />;
case 'settings':
return <SettingsSection />;
case 'env':
return <EnvSettingsSection />;
case 'maintenance':
return <MaintenanceSection />;
case 'workflows':
return <WorkflowsSection />;
case 'roles':
@@ -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 <RestartBadge />;
if (setting.hotReload) {
return (
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '4px',
backgroundColor: 'rgba(34, 197, 94, 0.12)',
color: 'var(--success)',
border: '1px solid rgba(34, 197, 94, 0.35)',
}}
>
可热生效
</span>
);
}
return null;
}
export const EnvSettingsSection: React.FC = () => {
const [settings, setSettings] = useState<SystemSetting[]>([]);
const [drafts, setDrafts] = useState<Record<string, SystemConfigValue>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取模型与密钥设置…" />;
if (error) return <SectionError message={error} onRetry={() => 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }}>
<SectionHeader
title="模型与密钥"
desc="持久化到 system_settings(覆盖 .env)。标注「可热生效」的项会推送到 PI runner;Embedding/Reranker/MinerU 需重启宿主。"
/>
{tip && (
<span
style={{
fontSize: '12px',
color: tip.type === 'success' ? 'var(--success)' : 'var(--danger)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
{tip.type === 'success' ? <CheckCircle2 size={14} /> : <AlertCircle size={14} />}
<span>{tip.msg}</span>
</span>
)}
</div>
{groups.length === 0 && <SectionLoading text="暂无模型/密钥设置" />}
{groups.map((g) => (
<div key={g.group} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>
{g.group === 'agent' ? '模型与端点' : g.group === 'secrets' ? '密钥' : g.group}
</div>
{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 (
<div
key={s.key}
style={{
...sectionCardStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-strong)' }}>
{s.label || s.key}
</span>
{s.secret && <KeyRound size={14} color="var(--text-muted)" />}
<EffectBadge setting={s} />
</div>
{s.description && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
{s.description}
</div>
)}
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
{s.key}
{s.secret && s.hasValue ? ` · 当前 ${s.masked || '****'}` : ''}
{s.secret && !s.hasValue ? ' · 未配置' : ''}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexShrink: 0 }}>
<input
type={s.secret ? 'password' : 'text'}
value={String(draft ?? '')}
placeholder={s.secret ? (s.hasValue ? '留空保持原值 / 填写新值' : '填写密钥') : undefined}
disabled={!s.writable}
autoComplete="off"
onChange={(e) => 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 && (
<button
type="button"
disabled={!canSave || savingKey === s.key}
onClick={() => void handleSave(s)}
style={{
padding: '6px 16px',
borderRadius: '6px',
background: canSave ? 'var(--brand-grad)' : 'var(--bg-app)',
color: canSave ? '#FFFFFF' : 'var(--text-faint)',
border: canSave ? 'none' : '1px solid var(--border)',
fontSize: '12px',
fontWeight: 600,
cursor: canSave && savingKey !== s.key ? 'pointer' : 'not-allowed',
}}
>
{savingKey === s.key ? '保存中…' : '保存'}
</button>
)}
</div>
</div>
);
})}
</div>
))}
</div>
);
};
@@ -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<MaintenanceWindow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取维护计划…" />;
if (error) return <SectionError message={error} onRetry={() => void load()} />;
const open = windows.find((w) => w.status === 'scheduled' || w.status === 'active');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }}>
<SectionHeader
title="维护计划"
desc="设置维护开始时间与预计时长。到期后主服务将把端口交接给公告页;升级完成后在公告页管理员入口用 token 启动新服务。"
/>
{tip && (
<span style={{ fontSize: '12px', color: tip.type === 'success' ? 'var(--success)' : 'var(--danger)', display: 'flex', alignItems: 'center', gap: '4px' }}>
{tip.type === 'success' ? <CheckCircle2 size={14} /> : <AlertCircle size={14} />}
{tip.msg}
</span>
)}
</div>
{open && (
<div style={{ ...sectionCardStyle, borderColor: open.status === 'active' ? 'rgba(228,87,75,.45)' : 'rgba(234,179,8,.45)' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>
当前:{open.status === 'active' ? '维护进行中' : '已计划'}
</div>
<div style={{ fontSize: '13px', color: 'var(--text-muted)', lineHeight: 1.6 }}>
{new Date(open.scheduledStart).toLocaleString('zh-CN')} — {new Date(open.scheduledEnd).toLocaleString('zh-CN')}
<br />
{open.message}
</div>
{open.status === 'scheduled' && (
<button
type="button"
onClick={() => void handleCancel(open.id)}
style={{ marginTop: '12px', padding: '6px 14px', borderRadius: '6px', border: '1px solid var(--border)', background: 'var(--bg-app)', cursor: 'pointer', fontSize: '12px' }}
>
取消计划
</button>
)}
{open.status === 'active' && (
<div style={{ marginTop: '12px', fontSize: '12px', color: 'var(--danger)' }}>
公告进程已接管端口。请在公告页 /admin 使用 data/*/maintenance-token 中的 token 启动新服务。
</div>
)}
</div>
)}
<div style={{ ...sectionCardStyle, display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ fontWeight: 600 }}>新建维护计划</div>
<label style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
开始时间
<input
type="datetime-local"
value={start}
onChange={(e) => setStart(e.target.value)}
disabled={Boolean(open)}
style={{ display: 'block', marginTop: '4px', width: '260px', padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--border)', background: 'var(--bg-app)', color: 'var(--text-strong)' }}
/>
</label>
<label style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
预计时长(小时)
<input
type="number"
min={0.5}
step={0.5}
value={hours}
onChange={(e) => setHours(Number(e.target.value))}
disabled={Boolean(open)}
style={{ display: 'block', marginTop: '4px', width: '120px', padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--border)', background: 'var(--bg-app)', color: 'var(--text-strong)' }}
/>
</label>
<label style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
公告文案
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={Boolean(open)}
rows={3}
style={{ display: 'block', marginTop: '4px', width: '100%', maxWidth: '480px', padding: '8px 10px', borderRadius: '6px', border: '1px solid var(--border)', background: 'var(--bg-app)', color: 'var(--text-strong)', fontSize: '13px' }}
/>
</label>
<button
type="button"
disabled={Boolean(open) || saving}
onClick={() => void handleSchedule()}
style={{
alignSelf: 'flex-start',
padding: '8px 18px',
borderRadius: '6px',
border: 'none',
background: open ? 'var(--bg-app)' : 'var(--brand-grad)',
color: open ? 'var(--text-faint)' : '#fff',
fontWeight: 600,
fontSize: '13px',
cursor: open || saving ? 'not-allowed' : 'pointer',
}}
>
{saving ? '提交中…' : '创建维护计划'}
</button>
</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>最近记录</div>
{windows.length === 0 && <SectionLoading text="暂无维护记录" />}
{windows.map((w) => (
<div key={w.id} style={{ ...sectionCardStyle, fontSize: '12px', color: 'var(--text-muted)' }}>
#{w.id} · {w.status} · {new Date(w.scheduledStart).toLocaleString('zh-CN')} → {new Date(w.scheduledEnd).toLocaleString('zh-CN')}
<div style={{ marginTop: '4px' }}>{w.message}</div>
</div>
))}
</div>
);
};
@@ -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) {
@@ -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<MaintenanceStatus | null>(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 (
<div
role="status"
style={{
padding: '10px 16px',
background: status.active ? 'rgba(228, 87, 75, 0.16)' : 'rgba(234, 179, 8, 0.16)',
borderBottom: `1px solid ${status.active ? 'rgba(228, 87, 75, 0.35)' : 'rgba(234, 179, 8, 0.35)'}`,
color: status.active ? 'var(--danger)' : '#a16207',
fontSize: '13px',
lineHeight: 1.5,
textAlign: 'center',
}}
>
{text}
</div>
);
};
/** 非管理员在 active 时全屏提示(主服务尚未退出前的过渡) */
export const MaintenanceOverlay: React.FC<{ status: MaintenanceStatus; onDismissAdmin?: () => void }> = ({
status,
}) => {
if (!status.active) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(15, 23, 42, 0.92)',
color: '#e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
}}
>
<div style={{ maxWidth: 480, textAlign: 'center' }}>
<h1 style={{ fontSize: 28, marginBottom: 12 }}>系统维护升级中</h1>
<p style={{ lineHeight: 1.6, color: '#cbd5e1' }}>{status.message || '请稍后再访问。'}</p>
{status.scheduledEnd && (
<p style={{ marginTop: 16, fontSize: 13, color: '#94a3b8' }}>
预计恢复:{formatWhen(status.scheduledEnd)}
</p>
)}
</div>
</div>
);
};
+26
View File
@@ -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;
}
+8 -2
View File
@@ -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;
}