diff --git a/backend/api/auth_deps.py b/backend/api/auth_deps.py index aa85c94..a67a606 100644 --- a/backend/api/auth_deps.py +++ b/backend/api/auth_deps.py @@ -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) diff --git a/backend/api/routes/accounts.py b/backend/api/routes/accounts.py index db80099..cdb4b26 100644 --- a/backend/api/routes/accounts.py +++ b/backend/api/routes/accounts.py @@ -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}") + diff --git a/backend/api/supervisor_account.py b/backend/api/supervisor_account.py new file mode 100644 index 0000000..119a0e7 --- /dev/null +++ b/backend/api/supervisor_account.py @@ -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), + ) + diff --git a/backend/database/models.py b/backend/database/models.py index e9a9692..bd039c6 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -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: """交易配置模型""" diff --git a/frontend/src/components/ConfigPanel.jsx b/frontend/src/components/ConfigPanel.jsx index 8fd1822..555dd40 100644 --- a/frontend/src/components/ConfigPanel.jsx +++ b/frontend/src/components/ConfigPanel.jsx @@ -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 }) => {

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

+ {/* 我的交易进程(按账号;owner/admin 可启停) */} +
+
+

我的交易进程(当前账号)

+
+ + {accountTradingStatus?.running ? '运行中' : '未运行/未知'} + + {accountTradingStatus?.pid ? PID: {accountTradingStatus.pid} : null} + {accountTradingStatus?.program ? 程序: {accountTradingStatus.program} : null} + {accountTradingStatus?.state ? 状态: {accountTradingStatus.state} : null} +
+
+
+ + + + +
+
+ 提示:若按钮报“无权限”,请让管理员在用户授权里把该账号分配为 owner;若报 supervisor 相关错误,请检查后端对 `/www/server/panel/plugin/supervisor` 的写权限与 supervisorctl 可执行权限。 +
+
+ {/* 系统控制:清缓存 / 启停 / 重启(supervisor) */} {isAdmin ? (
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 1a0fddc..1007596 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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() });