This commit is contained in:
薇薇安 2026-01-21 17:40:25 +08:00
parent 3b0ff0227e
commit fe60f12ee0
4 changed files with 147 additions and 12 deletions

View File

@ -81,7 +81,8 @@ async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> Lis
return out return out
memberships = UserAccountMembership.list_for_user(int(user["id"])) memberships = UserAccountMembership.list_for_user(int(user["id"]))
account_ids = [int(m.get("account_id")) for m in (memberships or []) if m.get("account_id") is not None] membership_map = {int(m.get("account_id")): (m.get("role") or "viewer") for m in (memberships or []) if m.get("account_id") is not None}
account_ids = list(membership_map.keys())
for aid in account_ids: for aid in account_ids:
r = Account.get(int(aid)) r = Account.get(int(aid))
if not r: if not r:
@ -94,6 +95,7 @@ async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> Lis
"name": r.get("name") or "", "name": r.get("name") or "",
"status": r.get("status") or "active", "status": r.get("status") or "active",
"use_testnet": bool(use_testnet), "use_testnet": bool(use_testnet),
"role": membership_map.get(int(aid), "viewer"),
} }
) )
return out return out
@ -167,8 +169,10 @@ async def update_account(account_id: int, payload: AccountUpdate, _admin: Dict[s
@router.put("/{account_id}/credentials") @router.put("/{account_id}/credentials")
async def update_credentials(account_id: int, payload: AccountCredentialsUpdate, _admin: Dict[str, Any] = Depends(get_admin_user)): async def update_credentials(account_id: int, payload: AccountCredentialsUpdate, user: Dict[str, Any] = Depends(get_current_user)):
try: try:
if (user.get("role") or "user") != "admin":
require_account_owner(int(account_id), user)
row = Account.get(int(account_id)) row = Account.get(int(account_id))
if not row: if not row:
raise HTTPException(status_code=404, detail="账号不存在") raise HTTPException(status_code=404, detail="账号不存在")
@ -213,11 +217,44 @@ async def trading_status_for_account(account_id: int, user: Dict[str, Any] = Dep
try: try:
raw = run_supervisorctl(["status", program]) raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw) running, pid, state = parse_supervisor_status(raw)
return {"program": program, "running": running, "pid": pid, "state": state, "raw": raw} # 仅 owner/admin 可看 tail便于自助排障
stderr_tail = ""
try:
is_admin = (user.get("role") or "user") == "admin"
role = UserAccountMembership.get_role(int(user["id"]), int(account_id)) if not is_admin else "admin"
if is_admin or role == "owner":
if state in {"FATAL", "EXITED", "BACKOFF"}:
stderr_tail = tail_supervisor(program, "stderr", 120)
except Exception:
stderr_tail = ""
resp = {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}
if stderr_tail:
resp["stderr_tail"] = stderr_tail
return resp
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"读取交易进程状态失败: {e}") raise HTTPException(status_code=500, detail=f"读取交易进程状态失败: {e}")
@router.get("/{account_id}/trading/tail")
async def trading_tail_for_account(
account_id: int,
stream: str = "stderr",
lines: int = 200,
user: Dict[str, Any] = Depends(get_current_user),
):
"""
读取该账号交易进程日志尾部用于排障 owner/admin 可读
"""
require_account_owner(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
out = tail_supervisor(program, stream=stream, lines=lines)
return {"program": program, "stream": stream, "lines": lines, "tail": out}
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取交易进程日志失败: {e}")
@router.post("/{account_id}/trading/start") @router.post("/{account_id}/trading/start")
async def trading_start_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)): async def trading_start_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
require_account_owner(int(account_id), user) require_account_owner(int(account_id), user)

View File

@ -15,7 +15,7 @@ sys.path.insert(0, str(project_root / 'backend'))
sys.path.insert(0, str(project_root / 'trading_system')) sys.path.insert(0, str(project_root / 'trading_system'))
from database.models import TradingConfig, Account from database.models import TradingConfig, Account
from api.auth_deps import get_current_user, get_account_id, require_admin from api.auth_deps import get_current_user, get_account_id, require_admin, require_account_owner
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -140,13 +140,13 @@ async def get_all_configs(
"value": _mask(api_key or ""), "value": _mask(api_key or ""),
"type": "string", "type": "string",
"category": "api", "category": "api",
"description": "币安API密钥账号私有仅脱敏展示仅管理员可修改)", "description": "币安API密钥账号私有仅脱敏展示账号 owner/admin 可修改)",
} }
result["BINANCE_API_SECRET"] = { result["BINANCE_API_SECRET"] = {
"value": "", "value": "",
"type": "string", "type": "string",
"category": "api", "category": "api",
"description": "币安API密钥Secret账号私有不回传明文仅管理员可修改)", "description": "币安API密钥Secret账号私有不回传明文账号 owner/admin 可修改)",
} }
result["USE_TESTNET"] = { result["USE_TESTNET"] = {
"value": bool(use_testnet), "value": bool(use_testnet),
@ -502,9 +502,14 @@ async def update_config(
): ):
"""更新配置""" """更新配置"""
try: try:
# 非管理员:必须是该账号 owner 才允许修改配置
if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
# API Key/Secret/Testnet写入 accounts 表(账号私有) # API Key/Secret/Testnet写入 accounts 表(账号私有)
if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}: if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
require_admin(user) if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
try: try:
if key == "BINANCE_API_KEY": if key == "BINANCE_API_KEY":
Account.update_credentials(account_id, api_key=str(item.value or "")) Account.update_credentials(account_id, api_key=str(item.value or ""))
@ -594,13 +599,17 @@ async def update_configs_batch(
): ):
"""批量更新配置""" """批量更新配置"""
try: try:
# 非管理员:必须是该账号 owner 才允许修改配置
if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
updated_count = 0 updated_count = 0
errors = [] errors = []
for item in configs: for item in configs:
try: try:
if item.key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}: if item.key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
require_admin(user) if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
# 验证配置值 # 验证配置值
if item.type == 'number': if item.type == 'number':
try: try:

View File

@ -105,9 +105,13 @@ class Account:
api_key = decrypt_str(row.get("api_key_enc") or "") api_key = decrypt_str(row.get("api_key_enc") or "")
api_secret = decrypt_str(row.get("api_secret_enc") or "") api_secret = decrypt_str(row.get("api_secret_enc") or "")
except Exception: except Exception:
# 兼容:无 cryptography 或未配 master key 时,先按明文兜底 # 兼容:无 cryptography 或未配 master key 时:
api_key = row.get("api_key_enc") or "" # - 若库里是明文,仍可工作
api_secret = row.get("api_secret_enc") or "" # - 若库里是 enc:v1 密文但未配 ATS_MASTER_KEY则不能解密也不能把密文当作 Key 使用
api_key_raw = row.get("api_key_enc") or ""
api_secret_raw = row.get("api_secret_enc") or ""
api_key = "" if str(api_key_raw).startswith("enc:v1:") else str(api_key_raw)
api_secret = "" if str(api_secret_raw).startswith("enc:v1:") else str(api_secret_raw)
use_testnet = bool(row.get("use_testnet") or False) use_testnet = bool(row.get("use_testnet") or False)
return api_key, api_secret, use_testnet return api_key, api_secret, use_testnet

View File

@ -14,6 +14,7 @@ const ConfigPanel = ({ currentUser }) => {
const [backendStatus, setBackendStatus] = useState(null) const [backendStatus, setBackendStatus] = useState(null)
const [systemBusy, setSystemBusy] = useState(false) const [systemBusy, setSystemBusy] = useState(false)
const [accountTradingStatus, setAccountTradingStatus] = useState(null) const [accountTradingStatus, setAccountTradingStatus] = useState(null)
const [accountTradingErr, setAccountTradingErr] = useState('')
// //
const [accountId, setAccountId] = useState(getCurrentAccountId()) const [accountId, setAccountId] = useState(getCurrentAccountId())
@ -212,8 +213,10 @@ const ConfigPanel = ({ currentUser }) => {
try { try {
const res = await api.getAccountTradingStatus(accountId) const res = await api.getAccountTradingStatus(accountId)
setAccountTradingStatus(res) setAccountTradingStatus(res)
setAccountTradingErr('')
} catch (error) { } catch (error) {
// owner/ supervisor setAccountTradingStatus(null)
setAccountTradingErr(error?.message || '获取交易进程状态失败')
} }
} }
@ -401,6 +404,7 @@ const ConfigPanel = ({ currentUser }) => {
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
const cur = getCurrentAccountId() const cur = getCurrentAccountId()
if (cur !== accountId) setAccountId(cur) if (cur !== accountId) setAccountId(cur)
}, 1000) }, 1000)
return () => clearInterval(timer) return () => clearInterval(timer)
@ -795,6 +799,87 @@ const ConfigPanel = ({ currentUser }) => {
<div className="system-hint"> <div className="system-hint">
提示若按钮报无权限请让管理员在用户授权里把该账号分配为 owner若报 supervisor 相关错误请检查后端对 `/www/server/panel/plugin/supervisor` 的写权限与 supervisorctl 可执行权限 提示若按钮报无权限请让管理员在用户授权里把该账号分配为 owner若报 supervisor 相关错误请检查后端对 `/www/server/panel/plugin/supervisor` 的写权限与 supervisorctl 可执行权限
</div> </div>
{accountTradingErr ? (
<div className="system-hint" style={{ color: '#b00020' }}>
{accountTradingErr}
</div>
) : null}
{accountTradingStatus?.raw ? (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer' }}>查看原始状态输出</summary>
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>{String(accountTradingStatus.raw || '')}</pre>
</details>
) : null}
{accountTradingStatus?.stderr_tail ? (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer' }}>查看最近错误日志stderr</summary>
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>{String(accountTradingStatus.stderr_tail || '')}</pre>
</details>
) : null}
</div>
{/* 账号密钥当前账号owner/admin 可改) */}
<div className="system-section">
<div className="system-header">
<h3>账号密钥当前账号</h3>
<div className="system-status">
<span className="system-status-meta">说明为安全起见页面不会回显 Secret填入后保存即可</span>
</div>
</div>
<div className="accounts-form">
<label>
API KEY留空=不改
<input
type="password"
value={credForm.api_key}
onChange={(e) => setCredForm({ ...credForm, api_key: e.target.value })}
placeholder="粘贴你的 Binance API KEY"
/>
</label>
<label>
API SECRET留空=不改
<input
type="password"
value={credForm.api_secret}
onChange={(e) => setCredForm({ ...credForm, api_secret: e.target.value })}
placeholder="粘贴你的 Binance API SECRET"
/>
</label>
<label className="accounts-inline">
<span>测试网</span>
<input
type="checkbox"
checked={!!credForm.use_testnet}
onChange={(e) => setCredForm({ ...credForm, use_testnet: e.target.checked })}
/>
</label>
<div className="accounts-form-actions">
<button
type="button"
className="system-btn primary"
disabled={systemBusy}
onClick={async () => {
setSystemBusy(true)
setMessage('')
try {
const payload = {}
if (credForm.api_key) payload.api_key = credForm.api_key
if (credForm.api_secret) payload.api_secret = credForm.api_secret
payload.use_testnet = !!credForm.use_testnet
await api.updateAccountCredentials(accountId, payload)
setMessage('密钥已更新(建议重启该账号交易进程)')
setCredForm({ api_key: '', api_secret: '', use_testnet: !!credForm.use_testnet })
} catch (e) {
setMessage('更新密钥失败: ' + (e?.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}}
>
保存密钥
</button>
</div>
</div>
</div> </div>
{/* 系统控制:清缓存 / 启停 / 重启supervisor */} {/* 系统控制:清缓存 / 启停 / 重启supervisor */}