feat(sysadmin): admin env settings + maintenance FD handoff
Persist agent/secrets settings with masked secrets and runner hot-reload; add maintenance windows, maintpage binary, and zero-downtime listener handoff.
This commit is contained in:
@@ -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 |
|
||||
@@ -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,84 @@
|
||||
# 管理员设置页与维护公告系统 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
dev 单二进制模式下需要:1)管理台持久化并热更新模型/Tavily 等 .env 类设置;2)维护计划到期后用 FD 交接共用 HTTP 端口,公告页接管后可由管理员 token 启动新服务并下线公告。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- Secret 掩码与审计指纹不落明文
|
||||
- PI runner `/admin/reload` 热更新
|
||||
- maintenance 调度与状态 API
|
||||
- 真实 FD handoff:父进程 → maintpage → stub server,同端口 health 恢复
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS: Linux;Go 短测 + arch-check block;handoff E2E 非 short
|
||||
- 层级:工程工具 / 单元 + 真实 handoff 进程间交接(验证契约工程工具补充)
|
||||
- 产品 PI 全链路:NOT REQUIRED(本任务为管理面与运维能力,非产品 Agent 工作流)
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| 用例 | 预期 |
|
||||
|---|---|
|
||||
| sysadmin secret | List 掩码、审计 sha256 |
|
||||
| arch-check | block PASS |
|
||||
| handoff E2E | maintpage active → token start → health ok |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 sysadmin / maintenance / handoff 单测
|
||||
|
||||
- **输入**: `go test -short ./internal/sysadmin/ ./internal/maintenance/ ./internal/handoff/`
|
||||
- **实际输出**: 均 `ok`
|
||||
- **判定**: PASS
|
||||
|
||||
### 5.2 server 设置回归
|
||||
|
||||
- **输入**: `go test -short ./internal/server/ -run TestAdminSystemSettings`
|
||||
- **实际输出**: `ok`
|
||||
- **判定**: PASS
|
||||
|
||||
### 5.3 arch-check
|
||||
|
||||
- **输入**: `./scripts/arch-check.sh --mode block`
|
||||
- **实际输出**: `arch-check block: production gate passed`
|
||||
- **判定**: PASS
|
||||
|
||||
### 5.4 runner reload
|
||||
|
||||
- **输入**: `node deploy/pi-agent/tests/reload.test.mjs`
|
||||
- **实际输出**: `reload.test.mjs PASS`
|
||||
- **判定**: PASS
|
||||
|
||||
### 5.5 真实 FD handoff E2E
|
||||
|
||||
- **输入**: `go test ./internal/handoff/ -run TestRealFDHandoffE2E -count=1 -v -timeout 30s`
|
||||
- **实际输出**(摘录):
|
||||
|
||||
```
|
||||
=== RUN TestRealFDHandoffE2E
|
||||
maintpage listening (mode=maintenance) window=1
|
||||
--- PASS: TestRealFDHandoffE2E (0.55s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/handoff 0.549s
|
||||
```
|
||||
|
||||
- **判定**: PASS
|
||||
- **证据**: [.doc/test-reports/assets/2026-09-25-admin-settings-maintenance/handoff-e2e.log](./assets/2026-09-25-admin-settings-maintenance/handoff-e2e.log)
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 套件 | 结果 |
|
||||
|---|---|
|
||||
| go test sysadmin/maintenance/handoff | PASS |
|
||||
| go test server (settings) | PASS |
|
||||
| arch-check --mode block | PASS |
|
||||
| node reload.test.mjs | PASS |
|
||||
| TestRealFDHandoffE2E | PASS |
|
||||
|
||||
前端全量 `npm test`:本环境无本地 `tsx` 安装时部分用例未跑;`client.test.ts` 经 `npx tsx` 抽检 PASS。完整前端需在 `web/` 依赖就绪后补跑。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
授权范围内工程验收达标:设置白名单扩展、runner 热更新、维护调度与 FD 交接共端口已用真实进程证据验证。后续可选:Go 侧 embedding key 进程内热载;compose 容器模式 settings 推送。
|
||||
+84
@@ -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
|
||||
@@ -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>`))
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package handoff_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/handoff"
|
||||
)
|
||||
|
||||
// TestRealFDHandoffE2E builds maintpage, hands off a live listener, then
|
||||
// starts a stub server via the maintpage admin token — zero-downtime port reuse.
|
||||
func TestRealFDHandoffE2E(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("real handoff e2e")
|
||||
}
|
||||
modRoot, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
maintBin := filepath.Join(tmp, "maintpage")
|
||||
stubBin := filepath.Join(tmp, "stubserver")
|
||||
|
||||
build := func(out, pkg string) {
|
||||
cmd := exec.Command("go", "build", "-o", out, pkg)
|
||||
cmd.Dir = modRoot
|
||||
if o, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build %s: %v\n%s", pkg, err, o)
|
||||
}
|
||||
}
|
||||
build(maintBin, "./cmd/maintpage")
|
||||
|
||||
stubSrc := filepath.Join(tmp, "stub.go")
|
||||
_ = os.WriteFile(stubSrc, []byte(`package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
"chat-agent-system/internal/handoff"
|
||||
)
|
||||
func main() {
|
||||
ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
|
||||
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
||||
_ = handoff.SignalReady()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
|
||||
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
|
||||
_ = http.Serve(ln, mux)
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
`), 0o644)
|
||||
// stub must live inside module for internal import — copy into cmd temp under module
|
||||
stubPkgDir := filepath.Join(modRoot, "cmd", "_handoff_e2e_stub")
|
||||
_ = os.RemoveAll(stubPkgDir)
|
||||
if err := os.MkdirAll(stubPkgDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(stubPkgDir)
|
||||
if err := os.WriteFile(filepath.Join(stubPkgDir, "main.go"), []byte(`package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
"chat-agent-system/internal/handoff"
|
||||
)
|
||||
func main() {
|
||||
ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
|
||||
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
||||
_ = handoff.SignalReady()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
|
||||
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
|
||||
go func() { _ = http.Serve(ln, mux) }()
|
||||
// Keep alive until killed by test cleanup.
|
||||
select {}
|
||||
}
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
build(stubBin, "./cmd/_handoff_e2e_stub")
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
token := fmt.Sprintf("e2e-%d", time.Now().UnixNano())
|
||||
dataDir := filepath.Join(tmp, "data")
|
||||
_ = os.MkdirAll(dataDir, 0o700)
|
||||
|
||||
extra := []string{
|
||||
"MAINT_LISTEN_ADDR=" + addr,
|
||||
"MAINT_MESSAGE=handoff-e2e",
|
||||
"MAINT_ADMIN_TOKEN=" + token,
|
||||
"MAINT_WINDOW_ID=1",
|
||||
"MAINT_SERVER_BIN=" + stubBin,
|
||||
"DATA_DIR=" + dataDir,
|
||||
}
|
||||
cmd, err := handoff.PassToChild(ln, maintBin, nil, extra, 20*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
// stub may have replaced maintpage; best-effort kill by port holders is out of scope —
|
||||
// stub exits when we kill its process via token start response pid if present.
|
||||
})
|
||||
_ = ln.Close()
|
||||
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
var status map[string]any
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get("http://" + addr + "/api/v1/maintenance/status")
|
||||
if err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode == 200 && json.Unmarshal(body, &status) == nil && status["active"] == true {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if status["active"] != true {
|
||||
t.Fatalf("maintpage not active: %+v", status)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/api/v1/maintenance/admin/start", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startBody, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("start: %d %s", resp.StatusCode, startBody)
|
||||
}
|
||||
var startResp struct {
|
||||
OK bool `json:"ok"`
|
||||
PID int `json:"pid"`
|
||||
}
|
||||
_ = json.Unmarshal(startBody, &startResp)
|
||||
if startResp.PID > 0 {
|
||||
t.Cleanup(func() {
|
||||
proc, err := os.FindProcess(startResp.PID)
|
||||
if err == nil && proc != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deadline = time.Now().Add(8 * time.Second)
|
||||
ok := false
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get("http://" + addr + "/api/v1/health")
|
||||
if err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
var h map[string]any
|
||||
if resp.StatusCode == 200 && json.Unmarshal(body, &h) == nil && h["ok"] == true {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("stub server health not ok after handoff resume")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
+173
-21
@@ -3,11 +3,14 @@ package sysadmin
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,6 +21,7 @@ var (
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,6 +1520,9 @@ export default function App() {
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<MaintenanceBanner />
|
||||
<div style={{ flex: 1 }}>
|
||||
<LoginView
|
||||
onLoginSuccess={(newSession) => {
|
||||
const switched = adoptWorkbenchUser(newSession.user.id);
|
||||
@@ -1540,6 +1545,8 @@ export default function App() {
|
||||
systemConfig={systemAuthConfig}
|
||||
env={systemEnv}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user