diff --git a/backend/api/routes/system.py b/backend/api/routes/system.py index 3660123..5aa0ee6 100644 --- a/backend/api/routes/system.py +++ b/backend/api/routes/system.py @@ -2,6 +2,7 @@ import os import re import subprocess import json +import time from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -16,6 +17,41 @@ router = APIRouter(prefix="/api/system") LOG_GROUPS = ("error", "warning", "info") +# 后端服务启动时间(用于前端展示“运行多久/是否已重启”) +_BACKEND_STARTED_AT_MS: int = int(time.time() * 1000) + +# 系统元信息存储(优先 Redis;用于记录重启时间等) +def _system_meta_prefix() -> str: + return (os.getenv("SYSTEM_META_PREFIX", "ats:system").strip() or "ats:system") + + +def _system_meta_key(name: str) -> str: + name = (name or "").strip() + return f"{_system_meta_prefix()}:{name}" + + +def _system_meta_read(name: str) -> Optional[Dict[str, Any]]: + client = _get_redis_client_for_logs() + if client is None: + return None + try: + raw = client.get(_system_meta_key(name)) + if not raw: + return None + return json.loads(raw) + except Exception: + return None + + +def _system_meta_write(name: str, payload: Dict[str, Any], ttl_sec: int = 30 * 24 * 3600) -> None: + client = _get_redis_client_for_logs() + if client is None: + return + try: + client.setex(_system_meta_key(name), int(ttl_sec), json.dumps(payload, ensure_ascii=False)) + except Exception: + return + # 避免 Redis 异常刷屏(前端可能自动刷新) _last_logs_redis_err_ts: float = 0.0 @@ -714,6 +750,7 @@ async def trading_status(x_admin_token: Optional[str] = Header(default=None, ali try: raw, resolved_name, status_all = _status_with_fallback(program) running, pid, state = _parse_supervisor_status(raw) + meta = _system_meta_read("trading:last_restart") or {} return { "mode": "supervisor", "program": program, @@ -723,6 +760,7 @@ async def trading_status(x_admin_token: Optional[str] = Header(default=None, ali "state": state, "raw": raw, "status_all": status_all, + "meta": meta, } except Exception as e: raise HTTPException( @@ -791,9 +829,22 @@ async def trading_restart(x_admin_token: Optional[str] = Header(default=None, al program = _get_program_name() try: + requested_at = _beijing_time_str() + requested_at_ms = int(time.time() * 1000) out, resolved_name, status_all = _action_with_fallback("restart", program) raw, resolved_name2, status_all2 = _status_with_fallback(resolved_name or program) running, pid, state = _parse_supervisor_status(raw) + + # 记录交易系统重启时间(用于前端展示) + _system_meta_write( + "trading:last_restart", + { + "requested_at": requested_at, + "requested_at_ms": requested_at_ms, + "pid": pid, + "program": resolved_name2 or resolved_name or program, + }, + ) return { "message": "交易系统已重启(supervisor)", "output": out, @@ -807,7 +858,89 @@ async def trading_restart(x_admin_token: Optional[str] = Header(default=None, al "raw": raw, "status_all": status_all2 or status_all, }, + "meta": { + "requested_at": requested_at, + "requested_at_ms": requested_at_ms, + }, } except Exception as e: raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}") + +@router.get("/backend/status") +async def backend_status(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + """ + 查看后端服务状态(当前 uvicorn 进程)。 + + 说明: + - pid 使用 os.getpid()(当前 FastAPI 进程) + - last_restart 从 Redis 读取(若可用) + """ + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + meta = _system_meta_read("backend:last_restart") or {} + return { + "running": True, + "pid": os.getpid(), + "started_at_ms": _BACKEND_STARTED_AT_MS, + "started_at": _beijing_time_str(), + "meta": meta, + } + + +@router.post("/backend/restart") +async def backend_restart(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + """ + 重启后端服务(uvicorn)。 + + 实现方式: + - 后端启动脚本为 nohup uvicorn ... & + - 这里通过后台启动 backend/restart.sh 来完成: + 1) grep 找到 uvicorn api.main:app 进程并 kill + 2) 再执行 backend/start.sh 拉起新进程 + + 注意: + - 为了让接口能先返回,这里会延迟 1s 再执行 restart.sh + """ + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + + backend_dir = Path(__file__).parent.parent.parent # backend/ + restart_script = backend_dir / "restart.sh" + if not restart_script.exists(): + raise HTTPException(status_code=500, detail=f"找不到重启脚本: {restart_script}") + + requested_at = _beijing_time_str() + requested_at_ms = int(time.time() * 1000) + cur_pid = os.getpid() + + _system_meta_write( + "backend:last_restart", + { + "requested_at": requested_at, + "requested_at_ms": requested_at_ms, + "pid_before": cur_pid, + "script": str(restart_script), + }, + ) + + # 后台执行:sleep 1 后再重启,保证当前请求可以返回 + cmd = ["bash", "-lc", f"sleep 1; '{restart_script}'"] + try: + subprocess.Popen( + cmd, + cwd=str(backend_dir), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"启动重启脚本失败: {e}") + + return { + "message": "已发起后端重启(1s 后执行)", + "pid_before": cur_pid, + "requested_at": requested_at, + "requested_at_ms": requested_at_ms, + "script": str(restart_script), + "note": "重启期间接口可能短暂不可用,页面可等待 3-5 秒后刷新状态。", + } + diff --git a/frontend/src/components/ConfigPanel.jsx b/frontend/src/components/ConfigPanel.jsx index 8830fdd..2517822 100644 --- a/frontend/src/components/ConfigPanel.jsx +++ b/frontend/src/components/ConfigPanel.jsx @@ -11,6 +11,7 @@ const ConfigPanel = () => { const [feasibilityCheck, setFeasibilityCheck] = useState(null) const [checkingFeasibility, setCheckingFeasibility] = useState(false) const [systemStatus, setSystemStatus] = useState(null) + const [backendStatus, setBackendStatus] = useState(null) const [systemBusy, setSystemBusy] = useState(false) // 配置快照(用于整体分析/导出) @@ -99,6 +100,15 @@ const ConfigPanel = () => { } } + const loadBackendStatus = async () => { + try { + const res = await api.getBackendStatus() + setBackendStatus(res) + } catch (error) { + // 静默失败:不影响配置页使用 + } + } + const handleClearCache = async () => { setSystemBusy(true) setMessage('') @@ -156,13 +166,33 @@ const ConfigPanel = () => { } } + const handleRestartBackend = async () => { + if (!window.confirm('确定要重启后端服务吗?重启期间页面接口会短暂不可用(约 3-10 秒)。')) return + setSystemBusy(true) + setMessage('') + try { + const res = await api.restartBackend() + setMessage(res.message || '已发起后端重启') + // 给后端一点时间完成重启,再刷新状态 + setTimeout(() => { + loadBackendStatus() + }, 4000) + } catch (error) { + setMessage('重启后端失败: ' + (error.message || '未知错误')) + } finally { + setSystemBusy(false) + } + } + useEffect(() => { loadConfigs() checkFeasibility() loadSystemStatus() + loadBackendStatus() const timer = setInterval(() => { loadSystemStatus() + loadBackendStatus() }, 3000) return () => clearInterval(timer) @@ -495,6 +525,7 @@ const ConfigPanel = () => { {systemStatus?.pid ? PID: {systemStatus.pid} : null} {systemStatus?.program ? 程序: {systemStatus.program} : null} + {systemStatus?.meta?.requested_at ? 上次重启: {systemStatus.meta.requested_at} : null}
@@ -534,6 +565,22 @@ const ConfigPanel = () => { > 重启交易系统 + +
+
+ + 后端 {backendStatus?.running ? '运行中' : '未知'} + + {backendStatus?.pid ? PID: {backendStatus.pid} : null} + {backendStatus?.meta?.requested_at ? 上次重启: {backendStatus.meta.requested_at} : null}
建议流程:先更新配置里的 Key → 点击“清除缓存” → 点击“重启交易系统”,确保不再使用旧账号下单。 diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index f7999c1..600735c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -298,6 +298,28 @@ export const api = { return response.json(); }, + // 后端控制(uvicorn) + getBackendStatus: async () => { + const response = await fetch(buildUrl('/api/system/backend/status')); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '获取后端状态失败' })); + throw new Error(error.detail || '获取后端状态失败'); + } + return response.json(); + }, + + restartBackend: async () => { + const response = await fetch(buildUrl('/api/system/backend/restart'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '重启后端失败' })); + throw new Error(error.detail || '重启后端失败'); + } + return response.json(); + }, + // 日志监控(Redis List) getSystemLogs: async (params = {}) => { const query = new URLSearchParams(params).toString();