This commit is contained in:
薇薇安 2026-01-21 11:04:44 +08:00
parent 1fdcb9c8b7
commit 6d48dc98d2
6 changed files with 543 additions and 2 deletions

View File

@ -67,6 +67,19 @@ def require_account_access(account_id: int, user: Dict[str, Any]) -> int:
raise HTTPException(status_code=403, detail="无权访问该账号")
def require_account_owner(account_id: int, user: Dict[str, Any]) -> int:
"""
账号拥有者权限用于启停交易进程等高危操作
"""
aid = int(account_id or 1)
if (user.get("role") or "user") == "admin":
return aid
role = UserAccountMembership.get_role(int(user["id"]), aid)
if role == "owner":
return aid
raise HTTPException(status_code=403, detail="需要该账号 owner 权限")
def get_admin_user(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
return require_admin(user)

View File

@ -12,7 +12,14 @@ from typing import Optional, List, Dict, Any
import logging
from database.models import Account, UserAccountMembership
from api.auth_deps import get_current_user, get_admin_user
from api.auth_deps import get_current_user, get_admin_user, require_account_access, require_account_owner
from api.supervisor_account import (
ensure_account_program,
run_supervisorctl,
parse_supervisor_status,
program_name_for_account,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@ -104,7 +111,24 @@ async def create_account(payload: AccountCreate, _admin: Dict[str, Any] = Depend
use_testnet=bool(payload.use_testnet),
status=payload.status,
)
return {"success": True, "id": int(aid), "message": "账号已创建"}
# 自动为该账号生成 supervisor program 配置(失败不影响账号创建)
sup = ensure_account_program(int(aid))
return {
"success": True,
"id": int(aid),
"message": "账号已创建",
"supervisor": {
"ok": bool(sup.ok),
"program": sup.program,
"program_dir": sup.program_dir,
"ini_path": sup.ini_path,
"supervisor_conf": sup.supervisor_conf,
"reread": sup.reread,
"update": sup.update,
"error": sup.error,
"note": "如需自动启停:请确保 backend 进程有写入 program_dir 权限,并允许执行 supervisorctl可选 sudo -n",
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建账号失败: {e}")
@ -160,3 +184,74 @@ async def update_credentials(account_id: int, payload: AccountCredentialsUpdate,
except Exception as e:
raise HTTPException(status_code=500, detail=f"更新账号密钥失败: {e}")
@router.post("/{account_id}/trading/ensure-program")
async def ensure_trading_program(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
# 允许管理员或该账号 owner 执行owner 用于“我重建配置再启动”)
if (user.get("role") or "user") != "admin":
require_account_owner(int(account_id), user)
sup = ensure_account_program(int(account_id))
if not sup.ok:
raise HTTPException(status_code=500, detail=sup.error or "生成 supervisor 配置失败")
return {
"ok": True,
"program": sup.program,
"ini_path": sup.ini_path,
"program_dir": sup.program_dir,
"supervisor_conf": sup.supervisor_conf,
"reread": sup.reread,
"update": sup.update,
}
@router.get("/{account_id}/trading/status")
async def trading_status_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
# 有访问权即可查看状态
require_account_access(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取交易进程状态失败: {e}")
@router.post("/{account_id}/trading/start")
async def trading_start_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
require_account_owner(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
out = run_supervisorctl(["start", program])
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"message": "已启动", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
except Exception as e:
raise HTTPException(status_code=500, detail=f"启动交易进程失败: {e}")
@router.post("/{account_id}/trading/stop")
async def trading_stop_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
require_account_owner(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
out = run_supervisorctl(["stop", program])
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"message": "已停止", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
except Exception as e:
raise HTTPException(status_code=500, detail=f"停止交易进程失败: {e}")
@router.post("/{account_id}/trading/restart")
async def trading_restart_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
require_account_owner(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
out = run_supervisorctl(["restart", program])
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"message": "已重启", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
except Exception as e:
raise HTTPException(status_code=500, detail=f"重启交易进程失败: {e}")

View File

@ -0,0 +1,270 @@
"""
Supervisor 多账号托管宝塔插件兼容
目标
- 根据 account_id 自动生成一个 supervisor program 配置文件.ini
- 自动定位 supervisord.conf include 目录尽量不要求你手填路径
- 提供 supervisorctl 的常用调用封装reread/update/status/start/stop/restart
重要说明
- 本模块只写入程序配置文件不包含任何 API Key/Secret
- trading_system 进程通过 ATS_ACCOUNT_ID 选择自己的账号配置
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
DEFAULT_CANDIDATE_CONFS = [
"/www/server/panel/plugin/supervisor/supervisord.conf",
"/www/server/panel/plugin/supervisor/supervisor.conf",
"/etc/supervisor/supervisord.conf",
"/etc/supervisord.conf",
]
def _get_project_root() -> Path:
# backend/api/supervisor_account.py -> api -> backend -> project_root
return Path(__file__).resolve().parents[2].parent
def _detect_supervisor_conf_path() -> Optional[Path]:
p = (os.getenv("SUPERVISOR_CONF") or "").strip()
if p:
pp = Path(p)
return pp if pp.exists() else pp # 允许不存在时也返回,便于报错信息
for cand in DEFAULT_CANDIDATE_CONFS:
try:
cp = Path(cand)
if cp.exists():
return cp
except Exception:
continue
return None
def _parse_include_dir_from_conf(conf_path: Path) -> Optional[Path]:
"""
尝试解析 supervisord.conf [include] files=... 目录
常见格式
[include]
files = /path/to/conf.d/*.ini
"""
try:
text = conf_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return None
in_include = False
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith(";") or line.startswith("#"):
continue
if re.match(r"^\[include\]\s*$", line, flags=re.I):
in_include = True
continue
if in_include and line.startswith("[") and line.endswith("]"):
break
if not in_include:
continue
m = re.match(r"^files\s*=\s*(.+)$", line, flags=re.I)
if not m:
continue
val = (m.group(1) or "").strip().strip('"').strip("'")
if not val:
continue
# 只取第一个 pattern即使写了多个用空格分隔
first = val.split()[0]
p = Path(first)
if not p.is_absolute():
p = (conf_path.parent / p).resolve()
return p.parent
return None
def get_supervisor_program_dir() -> Path:
"""
获取 supervisor program 配置目录优先级
1) SUPERVISOR_PROGRAM_DIR
2) supervisord.conf [include] files= 解析
3) 兜底/www/server/panel/plugin/supervisor你当前看到的目录
"""
env_dir = (os.getenv("SUPERVISOR_PROGRAM_DIR") or "").strip()
if env_dir:
return Path(env_dir)
conf = _detect_supervisor_conf_path()
if conf and conf.exists():
inc = _parse_include_dir_from_conf(conf)
if inc:
return inc
return Path("/www/server/panel/plugin/supervisor")
def program_name_for_account(account_id: int) -> str:
tmpl = (os.getenv("SUPERVISOR_TRADING_PROGRAM_TEMPLATE") or "auto_sys_acc{account_id}").strip()
try:
return tmpl.format(account_id=int(account_id))
except Exception:
return f"auto_sys_acc{int(account_id)}"
def ini_filename_for_program(program_name: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9_\-:.]+", "_", program_name).strip("_") or "auto_sys"
return f"{safe}.ini"
def render_program_ini(account_id: int, program_name: str) -> str:
project_root = _get_project_root()
python_bin = sys.executable # 使用 backend 当前虚拟环境,通常已包含 trading_system 依赖
# 日志目录可通过环境变量覆盖
log_dir = Path(os.getenv("TRADING_LOG_DIR", str(project_root / "logs"))).expanduser()
out_log = log_dir / f"trading_{int(account_id)}.out.log"
err_log = log_dir / f"trading_{int(account_id)}.err.log"
# 默认不自动启动,避免“创建账号=立刻下单”
autostart = (os.getenv("TRADING_AUTOSTART_DEFAULT", "false") or "false").lower() == "true"
return "\n".join(
[
f"[program:{program_name}]",
f"directory={project_root}",
f"command={python_bin} -m trading_system.main",
"autostart=" + ("true" if autostart else "false"),
"autorestart=true",
"startsecs=3",
"stopasgroup=true",
"killasgroup=true",
"",
f'environment=ATS_ACCOUNT_ID="{int(account_id)}",PYTHONUNBUFFERED="1"',
"",
f"stdout_logfile={out_log}",
f"stderr_logfile={err_log}",
"",
]
)
def write_program_ini(program_dir: Path, filename: str, content: str) -> Path:
program_dir.mkdir(parents=True, exist_ok=True)
target = program_dir / filename
tmp = program_dir / (filename + ".tmp")
tmp.write_text(content, encoding="utf-8")
os.replace(str(tmp), str(target))
return target
def _build_supervisorctl_cmd(args: list[str]) -> list[str]:
supervisorctl_path = os.getenv("SUPERVISORCTL_PATH", "supervisorctl")
supervisor_conf = (os.getenv("SUPERVISOR_CONF") or "").strip()
use_sudo = (os.getenv("SUPERVISOR_USE_SUDO", "false") or "false").lower() == "true"
if not supervisor_conf:
conf = _detect_supervisor_conf_path()
supervisor_conf = str(conf) if conf else ""
cmd: list[str] = []
if use_sudo:
cmd += ["sudo", "-n"]
cmd += [supervisorctl_path]
if supervisor_conf:
cmd += ["-c", supervisor_conf]
cmd += args
return cmd
def run_supervisorctl(args: list[str], timeout_sec: int = 10) -> str:
cmd = _build_supervisorctl_cmd(args)
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=int(timeout_sec))
except subprocess.TimeoutExpired:
raise RuntimeError("supervisorctl 超时")
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]:
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"
@dataclass
class EnsureProgramResult:
ok: bool
program: str
ini_path: str
program_dir: str
supervisor_conf: str
reread: str = ""
update: str = ""
error: str = ""
def ensure_account_program(account_id: int) -> EnsureProgramResult:
aid = int(account_id)
program = program_name_for_account(aid)
program_dir = get_supervisor_program_dir()
ini_name = ini_filename_for_program(program)
ini_text = render_program_ini(aid, program)
conf = _detect_supervisor_conf_path()
conf_s = str(conf) if conf else (os.getenv("SUPERVISOR_CONF") or "")
try:
path = write_program_ini(program_dir, ini_name, ini_text)
reread_out = ""
update_out = ""
try:
reread_out = run_supervisorctl(["reread"])
update_out = run_supervisorctl(["update"])
except Exception as e:
# 写文件成功但 supervisorctl 失败也要给出可诊断信息
return EnsureProgramResult(
ok=False,
program=program,
ini_path=str(path),
program_dir=str(program_dir),
supervisor_conf=conf_s,
reread=reread_out,
update=update_out,
error=f"写入配置成功,但执行 supervisorctl reread/update 失败: {e}",
)
return EnsureProgramResult(
ok=True,
program=program,
ini_path=str(path),
program_dir=str(program_dir),
supervisor_conf=conf_s,
reread=reread_out,
update=update_out,
)
except Exception as e:
return EnsureProgramResult(
ok=False,
program=program,
ini_path="",
program_dir=str(program_dir),
supervisor_conf=conf_s,
error=str(e),
)

View File

@ -189,6 +189,14 @@ class UserAccountMembership:
)
return bool(row)
@staticmethod
def get_role(user_id: int, account_id: int) -> str:
row = db.execute_one(
"SELECT role FROM user_account_memberships WHERE user_id = %s AND account_id = %s",
(int(user_id), int(account_id)),
)
return (row.get("role") if isinstance(row, dict) else None) or ""
class TradingConfig:
"""交易配置模型"""

View File

@ -13,6 +13,7 @@ const ConfigPanel = ({ currentUser }) => {
const [systemStatus, setSystemStatus] = useState(null)
const [backendStatus, setBackendStatus] = useState(null)
const [systemBusy, setSystemBusy] = useState(false)
const [accountTradingStatus, setAccountTradingStatus] = useState(null)
//
const [accountId, setAccountId] = useState(getCurrentAccountId())
@ -207,6 +208,71 @@ const ConfigPanel = ({ currentUser }) => {
}
}
const loadAccountTradingStatus = async () => {
try {
const res = await api.getAccountTradingStatus(accountId)
setAccountTradingStatus(res)
} catch (error) {
// owner/ supervisor
}
}
const handleAccountTradingEnsure = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.ensureAccountTradingProgram(accountId)
setMessage(`已生成/刷新 supervisor 配置:${res.program || ''}`)
await loadAccountTradingStatus()
} catch (error) {
setMessage('生成 supervisor 配置失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleAccountTradingStart = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.startAccountTrading(accountId)
setMessage(res.message || '交易进程已启动')
await loadAccountTradingStatus()
} catch (error) {
setMessage('启动交易进程失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleAccountTradingStop = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.stopAccountTrading(accountId)
setMessage(res.message || '交易进程已停止')
await loadAccountTradingStatus()
} catch (error) {
setMessage('停止交易进程失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleAccountTradingRestart = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.restartAccountTrading(accountId)
setMessage(res.message || '交易进程已重启')
await loadAccountTradingStatus()
} catch (error) {
setMessage('重启交易进程失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleClearCache = async () => {
setSystemBusy(true)
setMessage('')
@ -287,10 +353,12 @@ const ConfigPanel = ({ currentUser }) => {
checkFeasibility()
loadSystemStatus()
loadBackendStatus()
loadAccountTradingStatus()
const timer = setInterval(() => {
loadSystemStatus()
loadBackendStatus()
loadAccountTradingStatus()
}, 3000)
return () => clearInterval(timer)
@ -322,6 +390,7 @@ const ConfigPanel = ({ currentUser }) => {
checkFeasibility()
loadSystemStatus()
loadBackendStatus()
loadAccountTradingStatus()
}, [accountId])
// localStorage
@ -692,6 +761,38 @@ const ConfigPanel = ({ currentUser }) => {
<p>修改配置后交易系统将在下次扫描时自动使用新配置</p>
</div>
{/* 我的交易进程按账号owner/admin 可启停) */}
<div className="system-section">
<div className="system-header">
<h3>我的交易进程当前账号</h3>
<div className="system-status">
<span className={`system-status-badge ${accountTradingStatus?.running ? 'running' : 'stopped'}`}>
{accountTradingStatus?.running ? '运行中' : '未运行/未知'}
</span>
{accountTradingStatus?.pid ? <span className="system-status-meta">PID: {accountTradingStatus.pid}</span> : null}
{accountTradingStatus?.program ? <span className="system-status-meta">程序: {accountTradingStatus.program}</span> : null}
{accountTradingStatus?.state ? <span className="system-status-meta">状态: {accountTradingStatus.state}</span> : null}
</div>
</div>
<div className="system-actions">
<button type="button" className="system-btn" onClick={handleAccountTradingEnsure} disabled={systemBusy} title="为该账号生成/刷新 supervisor program 配置(需要 owner/admin">
生成配置
</button>
<button type="button" className="system-btn" onClick={handleAccountTradingStop} disabled={systemBusy} title="停止该账号交易进程(需要 owner/admin">
停止
</button>
<button type="button" className="system-btn" onClick={handleAccountTradingStart} disabled={systemBusy} title="启动该账号交易进程(需要 owner/admin">
启动
</button>
<button type="button" className="system-btn primary" onClick={handleAccountTradingRestart} disabled={systemBusy} title="重启该账号交易进程(需要 owner/admin">
重启
</button>
</div>
<div className="system-hint">
提示若按钮报无权限请让管理员在用户授权里把该账号分配为 owner若报 supervisor 相关错误请检查后端对 `/www/server/panel/plugin/supervisor` 的写权限与 supervisorctl 可执行权限
</div>
</div>
{/* 系统控制:清缓存 / 启停 / 重启supervisor */}
{isAdmin ? (
<div className="system-section">

View File

@ -136,6 +136,60 @@ export const api = {
return response.json();
},
// 交易进程(按账号;需要 owner 或 admin
getAccountTradingStatus: async (accountId) => {
const response = await fetch(buildUrl(`/api/accounts/${accountId}/trading/status`), { headers: withAccountHeaders() })
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取交易进程状态失败' }))
throw new Error(error.detail || '获取交易进程状态失败')
}
return response.json()
},
startAccountTrading: async (accountId) => {
const response = await fetch(buildUrl(`/api/accounts/${accountId}/trading/start`), {
method: 'POST',
headers: withAccountHeaders({ 'Content-Type': 'application/json' }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '启动交易进程失败' }))
throw new Error(error.detail || '启动交易进程失败')
}
return response.json()
},
stopAccountTrading: async (accountId) => {
const response = await fetch(buildUrl(`/api/accounts/${accountId}/trading/stop`), {
method: 'POST',
headers: withAccountHeaders({ 'Content-Type': 'application/json' }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '停止交易进程失败' }))
throw new Error(error.detail || '停止交易进程失败')
}
return response.json()
},
restartAccountTrading: async (accountId) => {
const response = await fetch(buildUrl(`/api/accounts/${accountId}/trading/restart`), {
method: 'POST',
headers: withAccountHeaders({ 'Content-Type': 'application/json' }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '重启交易进程失败' }))
throw new Error(error.detail || '重启交易进程失败')
}
return response.json()
},
ensureAccountTradingProgram: async (accountId) => {
const response = await fetch(buildUrl(`/api/accounts/${accountId}/trading/ensure-program`), {
method: 'POST',
headers: withAccountHeaders({ 'Content-Type': 'application/json' }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '生成/刷新 supervisor 配置失败' }))
throw new Error(error.detail || '生成/刷新 supervisor 配置失败')
}
return response.json()
},
// 配置管理
getConfigs: async () => {
const response = await fetch(buildUrl('/api/config'), { headers: withAccountHeaders() });