diff --git a/backend/api/main.py b/backend/api/main.py index 4d20391..d1b104c 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -3,7 +3,7 @@ FastAPI应用主入口 """ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import config, trades, stats, dashboard, account, recommendations +from api.routes import config, trades, stats, dashboard, account, recommendations, system import os import logging from pathlib import Path @@ -151,6 +151,7 @@ app.include_router(stats.router, prefix="/api/stats", tags=["统计分析"]) app.include_router(dashboard.router, prefix="/api/dashboard", tags=["仪表板"]) app.include_router(account.router, prefix="/api/account", tags=["账户数据"]) app.include_router(recommendations.router, tags=["交易推荐"]) +app.include_router(system.router, tags=["系统控制"]) @app.get("/") diff --git a/backend/api/routes/system.py b/backend/api/routes/system.py new file mode 100644 index 0000000..701d58b --- /dev/null +++ b/backend/api/routes/system.py @@ -0,0 +1,240 @@ +import os +import re +import subprocess +from typing import Any, Dict, Optional, Tuple + +from fastapi import APIRouter, HTTPException, Header +import logging + +logger = logging.getLogger(__name__) + +# 路由统一挂在 /api/system 下,前端直接调用 /api/system/... +router = APIRouter(prefix="/api/system") + + +def _require_admin(token: Optional[str], provided: Optional[str]) -> None: + """ + 可选的简单保护:如果环境变量配置了 SYSTEM_CONTROL_TOKEN,则要求请求携带 X-Admin-Token。 + 生产环境强烈建议通过 Nginx 额外做鉴权 / IP 白名单。 + """ + if not token: + return + if not provided or provided != token: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _build_supervisorctl_cmd(args: list[str]) -> list[str]: + supervisorctl_path = os.getenv("SUPERVISORCTL_PATH", "supervisorctl") + supervisor_conf = os.getenv("SUPERVISOR_CONF", "").strip() + use_sudo = os.getenv("SUPERVISOR_USE_SUDO", "false").lower() == "true" + + cmd: list[str] = [] + if use_sudo: + # 需要你在 sudoers 配置 NOPASSWD(sudo -n 才不会卡住) + cmd += ["sudo", "-n"] + cmd += [supervisorctl_path] + if supervisor_conf: + cmd += ["-c", supervisor_conf] + cmd += args + return cmd + + +def _run_supervisorctl(args: list[str]) -> str: + cmd = _build_supervisorctl_cmd(args) + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + except subprocess.TimeoutExpired: + raise RuntimeError("supervisorctl 超时(10s)") + + out = (res.stdout or "").strip() + err = (res.stderr or "").strip() + combined = "\n".join([s for s in [out, err] if s]).strip() + if res.returncode != 0: + raise RuntimeError(combined or f"supervisorctl failed (exit={res.returncode})") + return combined or out + + +def _parse_supervisor_status(raw: str) -> Tuple[bool, Optional[int], str]: + """ + 典型输出: + - auto_sys RUNNING pid 1234, uptime 0:10:00 + - auto_sys STOPPED Not started + """ + if "RUNNING" in raw: + m = re.search(r"\bpid\s+(\d+)\b", raw) + pid = int(m.group(1)) if m else None + return True, pid, "RUNNING" + for state in ["STOPPED", "FATAL", "EXITED", "BACKOFF", "STARTING", "UNKNOWN"]: + if state in raw: + return False, None, state + return False, None, "UNKNOWN" + + +def _get_program_name() -> str: + # 你给的宝塔配置是 [program:auto_sys] + return os.getenv("SUPERVISOR_TRADING_PROGRAM", "auto_sys").strip() or "auto_sys" + + +@router.post("/clear-cache") +async def clear_cache(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + """ + 清理配置缓存(Redis Hash: trading_config),并从数据库回灌到 Redis。 + """ + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + + try: + import config_manager + + cm = getattr(config_manager, "config_manager", None) + if cm is None: + raise HTTPException(status_code=500, detail="config_manager 未初始化") + + deleted_keys: list[str] = [] + + # 1) 清 backend 本地 cache + try: + cm._cache = {} + except Exception: + pass + + # 2) 清 Redis 缓存 key(Hash: trading_config) + try: + redis_client = getattr(cm, "_redis_client", None) + redis_connected = getattr(cm, "_redis_connected", False) + if redis_client is not None and redis_connected: + try: + redis_client.ping() + except Exception: + redis_connected = False + + if redis_client is not None and redis_connected: + try: + redis_client.delete("trading_config") + deleted_keys.append("trading_config") + except Exception as e: + logger.warning(f"删除 Redis key trading_config 失败: {e}") + + # 可选:实时推荐缓存(如果存在) + try: + redis_client.delete("recommendations:realtime") + deleted_keys.append("recommendations:realtime") + except Exception: + pass + except Exception as e: + logger.warning(f"清 Redis 缓存失败: {e}") + + # 3) 立刻从 DB 回灌到 Redis(避免 trading_system 读到空) + try: + cm.reload() + except Exception as e: + logger.warning(f"回灌配置到 Redis 失败(仍可能使用DB/本地cache): {e}") + + return { + "message": "缓存已清理并回灌", + "deleted_keys": deleted_keys, + "note": "如果你使用 supervisor 管理交易系统,请点击“重启交易系统”让新 Key 立即生效。", + } + except HTTPException: + raise + except Exception as e: + logger.error(f"清理缓存失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/trading/status") +async def trading_status(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + + program = _get_program_name() + try: + raw = _run_supervisorctl(["status", program]) + running, pid, state = _parse_supervisor_status(raw) + return { + "mode": "supervisor", + "program": program, + "running": running, + "pid": pid, + "state": state, + "raw": raw, + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"supervisorctl status 失败: {e}. 你可能需要配置 SUPERVISOR_CONF / SUPERVISOR_TRADING_PROGRAM / SUPERVISOR_USE_SUDO", + ) + + +@router.post("/trading/start") +async def trading_start(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + + program = _get_program_name() + try: + out = _run_supervisorctl(["start", program]) + raw = _run_supervisorctl(["status", program]) + running, pid, state = _parse_supervisor_status(raw) + return { + "message": "交易系统已启动(supervisor)", + "output": out, + "status": { + "mode": "supervisor", + "program": program, + "running": running, + "pid": pid, + "state": state, + "raw": raw, + }, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"supervisorctl start 失败: {e}") + + +@router.post("/trading/stop") +async def trading_stop(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + + program = _get_program_name() + try: + out = _run_supervisorctl(["stop", program]) + raw = _run_supervisorctl(["status", program]) + running, pid, state = _parse_supervisor_status(raw) + return { + "message": "交易系统已停止(supervisor)", + "output": out, + "status": { + "mode": "supervisor", + "program": program, + "running": running, + "pid": pid, + "state": state, + "raw": raw, + }, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"supervisorctl stop 失败: {e}") + + +@router.post("/trading/restart") +async def trading_restart(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]: + _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token) + + program = _get_program_name() + try: + out = _run_supervisorctl(["restart", program]) + raw = _run_supervisorctl(["status", program]) + running, pid, state = _parse_supervisor_status(raw) + return { + "message": "交易系统已重启(supervisor)", + "output": out, + "status": { + "mode": "supervisor", + "program": program, + "running": running, + "pid": pid, + "state": state, + "raw": raw, + }, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}") + diff --git a/frontend/src/components/ConfigPanel.css b/frontend/src/components/ConfigPanel.css index 6fb00ea..b5b086e 100644 --- a/frontend/src/components/ConfigPanel.css +++ b/frontend/src/components/ConfigPanel.css @@ -209,6 +209,111 @@ font-size: 0.9rem; } +.system-section { + margin-top: 1rem; + padding: 1rem; + border-radius: 8px; + border: 1px solid #e9ecef; + background: #fff; +} + +.system-header { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +@media (min-width: 768px) { + .system-header { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +.system-section h3 { + margin: 0; + color: #34495e; + font-size: 1rem; +} + +.system-status { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.system-status-badge { + padding: 0.3rem 0.7rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 700; +} + +.system-status-badge.running { + background: #e8f5e9; + color: #2e7d32; + border: 1px solid #c8e6c9; +} + +.system-status-badge.stopped { + background: #fff3e0; + color: #e65100; + border: 1px solid #ffe0b2; +} + +.system-status-meta { + font-size: 0.9rem; + color: #666; +} + +.system-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.system-btn { + padding: 0.5rem 0.9rem; + border-radius: 6px; + border: 1px solid #dee2e6; + background: #f8f9fa; + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; + font-weight: 600; +} + +.system-btn:hover:not(:disabled) { + border-color: #2196F3; + background: #e7f3ff; +} + +.system-btn.primary { + border-color: #2196F3; + background: #2196F3; + color: #fff; +} + +.system-btn.primary:hover:not(:disabled) { + background: #1976D2; + border-color: #1976D2; +} + +.system-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.system-hint { + margin-top: 0.75rem; + font-size: 0.85rem; + color: #666; + line-height: 1.4; +} + .message { padding: 1rem; margin-bottom: 1rem; diff --git a/frontend/src/components/ConfigPanel.jsx b/frontend/src/components/ConfigPanel.jsx index bb69ead..e64852a 100644 --- a/frontend/src/components/ConfigPanel.jsx +++ b/frontend/src/components/ConfigPanel.jsx @@ -10,6 +10,8 @@ const ConfigPanel = () => { const [message, setMessage] = useState('') const [feasibilityCheck, setFeasibilityCheck] = useState(null) const [checkingFeasibility, setCheckingFeasibility] = useState(false) + const [systemStatus, setSystemStatus] = useState(null) + const [systemBusy, setSystemBusy] = useState(false) // 预设方案配置 // 注意:百分比配置使用整数形式(如8.0表示8%),在应用时会转换为小数(0.08) @@ -64,9 +66,82 @@ const ConfigPanel = () => { } } + const loadSystemStatus = async () => { + try { + const res = await api.getTradingSystemStatus() + setSystemStatus(res) + } catch (error) { + // 静默失败:不影响配置页使用 + } + } + + const handleClearCache = async () => { + setSystemBusy(true) + setMessage('') + try { + const res = await api.clearSystemCache() + setMessage(res.message || '缓存已清理') + await loadConfigs() + await loadSystemStatus() + } catch (error) { + setMessage('清理缓存失败: ' + (error.message || '未知错误')) + } finally { + setSystemBusy(false) + } + } + + const handleStartTrading = async () => { + setSystemBusy(true) + setMessage('') + try { + const res = await api.startTradingSystem() + setMessage(res.message || '交易系统已启动') + await loadSystemStatus() + } catch (error) { + setMessage('启动失败: ' + (error.message || '未知错误')) + } finally { + setSystemBusy(false) + } + } + + const handleStopTrading = async () => { + setSystemBusy(true) + setMessage('') + try { + const res = await api.stopTradingSystem() + setMessage(res.message || '交易系统已停止') + await loadSystemStatus() + } catch (error) { + setMessage('停止失败: ' + (error.message || '未知错误')) + } finally { + setSystemBusy(false) + } + } + + const handleRestartTrading = async () => { + setSystemBusy(true) + setMessage('') + try { + const res = await api.restartTradingSystem() + setMessage(res.message || '交易系统已重启') + await loadSystemStatus() + } catch (error) { + setMessage('重启失败: ' + (error.message || '未知错误')) + } finally { + setSystemBusy(false) + } + } + useEffect(() => { loadConfigs() checkFeasibility() + loadSystemStatus() + + const timer = setInterval(() => { + loadSystemStatus() + }, 3000) + + return () => clearInterval(timer) }, []) const checkFeasibility = async () => { @@ -246,6 +321,61 @@ const ConfigPanel = () => {

修改配置后,交易系统将在下次扫描时自动使用新配置

+ + {/* 系统控制:清缓存 / 启停 / 重启(supervisor) */} +
+
+

系统控制

+
+ + {systemStatus?.running ? '运行中' : '未运行'} + + {systemStatus?.pid ? PID: {systemStatus.pid} : null} + {systemStatus?.program ? 程序: {systemStatus.program} : null} +
+
+
+ + + + +
+
+ 建议流程:先更新配置里的 Key → 点击“清除缓存” → 点击“重启交易系统”,确保不再使用旧账号下单。 +
+
{/* 预设方案快速切换 */}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 7e6671a..17c63fa 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -239,4 +239,62 @@ export const api = { } return response.json(); }, + + // 系统控制(supervisor) + clearSystemCache: async () => { + const response = await fetch(buildUrl('/api/system/clear-cache'), { + 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(); + }, + + getTradingSystemStatus: async () => { + const response = await fetch(buildUrl('/api/system/trading/status')); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '获取交易系统状态失败' })); + throw new Error(error.detail || '获取交易系统状态失败'); + } + return response.json(); + }, + + startTradingSystem: async () => { + const response = await fetch(buildUrl('/api/system/trading/start'), { + 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(); + }, + + stopTradingSystem: async () => { + const response = await fetch(buildUrl('/api/system/trading/stop'), { + 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(); + }, + + restartTradingSystem: async () => { + const response = await fetch(buildUrl('/api/system/trading/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(); + }, };