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.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("/")

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 配置 NOPASSWDsudo -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 缓存 keyHash: 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;
}
.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;

View File

@ -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.08%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 = () => {
<div className="config-info">
<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">

View File

@ -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();
},
};