a
This commit is contained in:
parent
ed662b0092
commit
38ebbebd95
|
|
@ -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("/")
|
||||
|
|
|
|||
240
backend/api/routes/system.py
Normal file
240
backend/api/routes/system.py
Normal file
|
|
@ -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}")
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
@ -247,6 +322,61 @@ const ConfigPanel = () => {
|
|||
<p>修改配置后,交易系统将在下次扫描时自动使用新配置</p>
|
||||
</div>
|
||||
|
||||
{/* 系统控制:清缓存 / 启停 / 重启(supervisor) */}
|
||||
<div className="system-section">
|
||||
<div className="system-header">
|
||||
<h3>系统控制</h3>
|
||||
<div className="system-status">
|
||||
<span className={`system-status-badge ${systemStatus?.running ? 'running' : 'stopped'}`}>
|
||||
{systemStatus?.running ? '运行中' : '未运行'}
|
||||
</span>
|
||||
{systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null}
|
||||
{systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="system-btn"
|
||||
onClick={handleClearCache}
|
||||
disabled={systemBusy}
|
||||
title="清理Redis配置缓存并从数据库回灌。切换API Key后建议先点这里,再重启交易系统。"
|
||||
>
|
||||
清除缓存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="system-btn"
|
||||
onClick={handleStopTrading}
|
||||
disabled={systemBusy || systemStatus?.running === false}
|
||||
title="通过 supervisorctl 停止交易系统"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="system-btn"
|
||||
onClick={handleStartTrading}
|
||||
disabled={systemBusy || systemStatus?.running === true}
|
||||
title="通过 supervisorctl 启动交易系统"
|
||||
>
|
||||
启动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="system-btn primary"
|
||||
onClick={handleRestartTrading}
|
||||
disabled={systemBusy}
|
||||
title="通过 supervisorctl 重启交易系统(建议切换API Key后使用)"
|
||||
>
|
||||
重启交易系统
|
||||
</button>
|
||||
</div>
|
||||
<div className="system-hint">
|
||||
建议流程:先更新配置里的 Key → 点击“清除缓存” → 点击“重启交易系统”,确保不再使用旧账号下单。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设方案快速切换 */}
|
||||
<div className="preset-section">
|
||||
<div className="preset-header">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user