This commit is contained in:
薇薇安 2026-01-18 16:39:57 +08:00
parent ed662b0092
commit 38ebbebd95
5 changed files with 535 additions and 1 deletions

View File

@ -3,7 +3,7 @@ FastAPI应用主入口
""" """
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 os
import logging import logging
from pathlib import Path 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(dashboard.router, prefix="/api/dashboard", tags=["仪表板"])
app.include_router(account.router, prefix="/api/account", tags=["账户数据"]) app.include_router(account.router, prefix="/api/account", tags=["账户数据"])
app.include_router(recommendations.router, tags=["交易推荐"]) app.include_router(recommendations.router, tags=["交易推荐"])
app.include_router(system.router, tags=["系统控制"])
@app.get("/") @app.get("/")

View 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}")

View File

@ -209,6 +209,111 @@
font-size: 0.9rem; 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 { .message {
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -10,6 +10,8 @@ const ConfigPanel = () => {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [feasibilityCheck, setFeasibilityCheck] = useState(null) const [feasibilityCheck, setFeasibilityCheck] = useState(null)
const [checkingFeasibility, setCheckingFeasibility] = useState(false) const [checkingFeasibility, setCheckingFeasibility] = useState(false)
const [systemStatus, setSystemStatus] = useState(null)
const [systemBusy, setSystemBusy] = useState(false)
// 预设方案配置 // 预设方案配置
// 注意:百分比配置使用整数形式(如8.0表示8%),在应用时会转换为小数(0.08) // 注意:百分比配置使用整数形式(如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(() => { useEffect(() => {
loadConfigs() loadConfigs()
checkFeasibility() checkFeasibility()
loadSystemStatus()
const timer = setInterval(() => {
loadSystemStatus()
}, 3000)
return () => clearInterval(timer)
}, []) }, [])
const checkFeasibility = async () => { const checkFeasibility = async () => {
@ -246,6 +321,61 @@ const ConfigPanel = () => {
<div className="config-info"> <div className="config-info">
<p>修改配置后,交易系统将在下次扫描时自动使用新配置</p> <p>修改配置后,交易系统将在下次扫描时自动使用新配置</p>
</div> </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-section">

View File

@ -239,4 +239,62 @@ export const api = {
} }
return response.json(); 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();
},
}; };