From 16efa44ffbe0942b48e5886403486873d43e64ff Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 26 Jul 2026 22:43:38 +0800 Subject: [PATCH] Hide manual trade by default, block open while running, auto-refresh auth token. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix flat-side reconcile to check both long and short residuals; document in 更新说明. Co-authored-by: Cursor --- backend/app/api/auth_routes.py | 16 +++++ backend/app/api/settings.py | 5 ++ backend/app/api/sim.py | 18 +++++ backend/app/live/reconcile.py | 17 +++-- backend/app/strategy/engine.py | 3 + backend/tests/test_auth_refresh.py | 20 ++++++ docs/更新说明.md | 32 +++++++++ frontend/src/App.tsx | 13 +++- frontend/src/api/client.ts | 107 ++++++++++++++++++++++++++++- frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Plan.tsx | 57 +++++++++------ frontend/src/pages/Settings.tsx | 33 +++++++-- frontend/src/styles/app.css | 19 +++++ 13 files changed, 304 insertions(+), 38 deletions(-) create mode 100644 backend/tests/test_auth_refresh.py diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py index 1d6b096..425a315 100644 --- a/backend/app/api/auth_routes.py +++ b/backend/app/api/auth_routes.py @@ -89,6 +89,22 @@ async def me( } +@router.post("/refresh", response_model=LoginResponse) +async def refresh_token( + username: Annotated[str, Depends(require_user)], + settings: Annotated[Settings, Depends(get_settings)], +) -> LoginResponse: + """用仍有效的 Bearer 换发新 HMAC token(自动轮换,无需重登)。""" + token, ttl = issue_token(username, settings) + return LoginResponse( + token=token, + username=username, + expires_in=ttl, + env_name=settings.env_name, + mode=settings.mode, + ) + + @router.post("/change-credentials", response_model=LoginResponse) async def change_credentials( body: ChangeCredentialsRequest, diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 5471821..6892886 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -44,6 +44,7 @@ KEYS = ( "close_bid_mark_max_pct", "perp_qty_eth", "option_qty_eth", + "show_manual_trade_buttons", ) @@ -65,6 +66,7 @@ class StrategySettingsBody(BaseModel): close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100) perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100) option_qty_eth: float | None = Field(default=None, ge=0.01, le=100) + show_manual_trade_buttons: bool | None = None exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$") @@ -139,6 +141,9 @@ def _read_settings() -> dict: "option_qty_eth": float( db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth ), + "show_manual_trade_buttons": _as_bool( + db.get_setting("show_manual_trade_buttons", "0"), False + ), "exchange": rt.exchange, "perp_inst_id": rt.perp_inst_id, "option_inst_family": rt.option_inst_family, diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index e09e422..a66a868 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -42,6 +42,19 @@ async def sim_open_group( ok, reason = live_ready() if not get_settings().is_sim and not ok: raise HTTPException(status_code=400, detail=reason) + from ..strategy import get_engine + + st = get_engine().state() + if st.get("running"): + raise HTTPException( + status_code=409, + detail="策略自动运行中,禁止手动开仓;请先暂停", + ) + if not Ledger().get_setting_bool("show_manual_trade_buttons", False): + raise HTTPException( + status_code=403, + detail="未开启「显示手动开仓」;请在策略设置中开启后再用", + ) ex = get_executor() if ex.has_open_position(): raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组") @@ -115,6 +128,11 @@ async def sim_open_group( @router.post("/close-group") async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict: + if not Ledger().get_setting_bool("show_manual_trade_buttons", False): + raise HTTPException( + status_code=403, + detail="未开启「显示手动开仓」;请在策略设置中开启后再用", + ) r = get_executor().close_group(reason="manual") if not r.ok and not r.liquidity_wait: raise HTTPException(status_code=400, detail=r.detail) diff --git a/backend/app/live/reconcile.py b/backend/app/live/reconcile.py index b4ab094..054991b 100644 --- a/backend/app/live/reconcile.py +++ b/backend/app/live/reconcile.py @@ -76,12 +76,19 @@ def assert_safe_to_open_live(executor) -> tuple[bool, str]: return False, "无法核对交易所持仓" perp_inst = resolve_perp_inst_id(executor.db) - perp_side = str(pos.get("perp_side") or "long") - ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, perp_side) - if ex_sz is None: - return False, "无法核对交易所持仓" + # flat/opening:两侧都查,避免只查默认 long 漏掉 short 残留 + if st in ("flat", "", "opening"): + sides = ("long", "short") + else: + sides = (str(pos.get("perp_side") or "long"),) + total = 0.0 + for side in sides: + ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side) + if ex_sz is None: + return False, "无法核对交易所持仓" + total += float(ex_sz) - if ex_sz > _PERP_EPS and st in ("flat", "", "opening"): + if total > _PERP_EPS and st in ("flat", "", "opening"): return ( False, "交易所有永续仓但本地无持仓,禁止新开,请人工核对", diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 5d3cb1d..65b0827 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -128,6 +128,9 @@ class StrategyEngine: "sim": s.is_sim, "live_ready": (live_ready()[0] if not s.is_sim else True), "live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"), + "show_manual_trade_buttons": self.ledger.get_setting_bool( + "show_manual_trade_buttons", False + ), } def _set_state(self, **kwargs: Any) -> None: diff --git a/backend/tests/test_auth_refresh.py b/backend/tests/test_auth_refresh.py new file mode 100644 index 0000000..22a527d --- /dev/null +++ b/backend/tests/test_auth_refresh.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from app.api.auth import issue_token, verify_token +from app.config import get_settings + + +def test_issue_and_verify_token_roundtrip(): + s = get_settings() + token, ttl = issue_token("admin", s) + assert ttl == s.auth_token_ttl_sec + assert verify_token(token, s) == "admin" + + +def test_refresh_mints_another_valid_token(): + s = get_settings() + t1, _ = issue_token("admin", s) + t2, ttl = issue_token("admin", s) + assert ttl > 0 + assert verify_token(t1, s) == "admin" + assert verify_token(t2, s) == "admin" diff --git a/docs/更新说明.md b/docs/更新说明.md index bb560a5..7c53ad3 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,38 @@ --- +## 2026-07-26 — LIVE 对账 / 手动开仓 UI / Token 自动换发 + +### 变更 + +1. **开仓幂等与对账**:`opening` 占槽;LIVE 开仓前核对交易所永续(双侧);启动写 mismatch 日志。 +2. **平仓张数**:OKX/币安平永续优先读交易所持仓,失败才回退本地账本。 +3. **杠杆**:LIVE 开永续前 `set_leverage`(失败仅告警)。 +4. **自动运行禁手动开仓**:`running=1` 时 `/api/sim/open-group` 返回 409;计划页按钮禁用。 +5. **手动开仓显示**:策略设置「节奏」→「显示手动开仓」,**默认不显示**;关闭时 API 亦拒绝手动开/手动全平。紧急全平始终可用且为**红色**。 +6. **启动态**:策略运行中主按钮文案为「启动中」,绿色样式。 +7. **Token 自动换发**:HMAC Bearer(非 Flask)。新增 `POST /api/auth/refresh`;前端登录后定时/切回前台/临近过期静默换发。默认口令未改。 + +### 审计(本包改后) + +| 项 | 状态 | +|----|------| +| 交易所↔本地对账 / 开仓幂等 | **已修** | +| 平仓张数读交易所 | **已修** | +| set_leverage | **已修**(失败告警) | +| 自动运行仍可手动开仓 | **已修**(API + UI) | +| 手动开/全平默认露出 | **已修**(默认隐藏 + 设置开关) | +| 紧急全平样式 / 启动中文案 | **已修** | +| Token 长期不轮换 | **已修**(refresh + 前端自动换发) | +| 对账只查单侧漏 short | **已修**(flat 时 long+short) | +| 放弃期权残留 / 模式切换仅本地 | **未做**(非本包;紧急全平/人工核对) | + +### 测试 + +- `pytest`:52 passed + +--- + ## 2026-07-26 — 审计修复包(安全 / 下单 / 平仓) ### 变更 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7240392..a253d2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,11 @@ -import type { ReactNode } from "react"; +import { type ReactNode, useEffect } from "react"; import { NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { clearSession, getToken, getUsername } from "./api/client"; +import { + clearSession, + getToken, + getUsername, + startAuthTokenAutoRefresh, +} from "./api/client"; import LoginPage from "./pages/Login"; import PlanPage from "./pages/Plan"; import TradesPage from "./pages/Trades"; @@ -58,6 +63,10 @@ function Shell({ children }: { children: ReactNode }) { } function RequireAuth({ children }: { children: ReactNode }) { + useEffect(() => { + if (!getToken()) return; + return startAuthTokenAutoRefresh(); + }, []); if (!getToken()) return ; return {children}; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 91c5844..722a249 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ const TOKEN_KEY = "eth_hedge_token"; const USER_KEY = "eth_hedge_user"; +const TOKEN_EXP_KEY = "eth_hedge_token_exp"; export function getApiBase(): string { return window.location.origin; @@ -9,14 +10,21 @@ export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } -export function setSession(token: string, username: string) { +export function setSession(token: string, username: string, expiresInSec?: number) { localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(USER_KEY, username); + if (expiresInSec != null && expiresInSec > 0) { + localStorage.setItem( + TOKEN_EXP_KEY, + String(Date.now() + expiresInSec * 1000), + ); + } } export function clearSession() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); + localStorage.removeItem(TOKEN_EXP_KEY); localStorage.removeItem("eth_hedge_api_base"); } @@ -24,10 +32,97 @@ export function getUsername(): string | null { return localStorage.getItem(USER_KEY); } +function tokenExpiresAtMs(): number | null { + const raw = localStorage.getItem(TOKEN_EXP_KEY); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +/** 距过期不足该毫秒则主动换发(默认 12h)。 */ +const REFRESH_BEFORE_MS = 12 * 60 * 60 * 1000; + +let _refreshInFlight: Promise | null = null; + +/** + * 用当前 Bearer 换发新 HMAC token(非 Flask session)。 + * 登录态下由定时器/页面可见时静默调用。 + */ +export async function refreshAuthToken(): Promise { + const token = getToken(); + if (!token) return false; + if (_refreshInFlight) return _refreshInFlight; + _refreshInFlight = (async () => { + try { + const res = await fetch(`${getApiBase()}/api/auth/refresh`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: "{}", + }); + if (!res.ok) return false; + const data = (await res.json()) as LoginResult; + if (!data?.token) return false; + setSession(data.token, data.username || getUsername() || "", data.expires_in); + return true; + } catch { + return false; + } finally { + _refreshInFlight = null; + } + })(); + return _refreshInFlight; +} + +let _lastRefreshAttemptMs = 0; + +/** 临近过期则刷新;返回是否仍持有有效 token。 */ +export async function ensureFreshToken(): Promise { + if (!getToken()) return false; + const exp = tokenExpiresAtMs(); + const need = + exp == null || exp - Date.now() < REFRESH_BEFORE_MS; + if (!need) return true; + const now = Date.now(); + // 无过期戳的旧会话:最多每 60s 尝试一次,避免轮询打爆 /refresh + if (exp == null && now - _lastRefreshAttemptMs < 60_000) { + return true; + } + _lastRefreshAttemptMs = now; + const ok = await refreshAuthToken(); + if (!ok && exp != null && exp <= now) { + clearSession(); + return false; + } + return !!getToken(); +} + +/** 在已登录壳内启动:定时换发 + 页面回到前台时检查。 */ +export function startAuthTokenAutoRefresh(): () => void { + const tick = () => { + void ensureFreshToken(); + }; + tick(); + const id = window.setInterval(tick, 30 * 60 * 1000); + const onVis = () => { + if (document.visibilityState === "visible") tick(); + }; + document.addEventListener("visibilitychange", onVis); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVis); + }; +} + export async function apiFetch( path: string, options: RequestInit = {}, ): Promise { + if (!path.startsWith("/api/auth/login")) { + await ensureFreshToken(); + } const base = getApiBase(); const headers = new Headers(options.headers || {}); if (!headers.has("Content-Type") && options.body) { @@ -67,10 +162,12 @@ export type LoginResult = { }; export async function login(username: string, password: string) { - return apiFetch("/api/auth/login", { + const res = await apiFetch("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }), }); + setSession(res.token, res.username, res.expires_in); + return res; } export async function changeCredentials(input: { @@ -78,10 +175,12 @@ export async function changeCredentials(input: { new_username: string; new_password: string; }) { - return apiFetch("/api/auth/change-credentials", { + const res = await apiFetch("/api/auth/change-credentials", { method: "POST", body: JSON.stringify(input), }); + setSession(res.token, res.username, res.expires_in); + return res; } export type MarketSnapshot = { @@ -135,6 +234,7 @@ export type PlanState = { max_atm_open_offset?: number; can_open: boolean; last_error: string | null; + show_manual_trade_buttons?: boolean; position: { has_position: boolean; group_id?: string; @@ -217,6 +317,7 @@ export type StrategySettings = { close_bid_mark_max_pct: number; perp_qty_eth: number; option_qty_eth: number; + show_manual_trade_buttons?: boolean; exchange: "okx" | "binance"; perp_inst_id?: string; option_inst_family?: string; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ac4499c..3f028cd 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -15,7 +15,7 @@ export default function LoginPage() { setLoading(true); try { const res = await login(username.trim(), password); - setSession(res.token, res.username); + setSession(res.token, res.username, res.expires_in); nav("/plan", { replace: true }); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index c4f5f8a..8f295e1 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -144,11 +144,11 @@ export default function PlanPage() {
+ {plan?.show_manual_trade_buttons ? ( + <> + + + + ) : null} - -
+
+ + +
) : null} @@ -607,10 +624,16 @@ export default function SettingsPage() { ) : null} {stratSub === "pace" ? ( -
  • - 实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围 - 0.2–30。 -
  • + <> +
  • + 实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围 + 0.2–30。 +
  • +
  • + 显示手动开仓:默认关闭。开启后计划页才出现「手动开一组 / + 手动全平」。策略自动运行中禁止手动开仓(须先暂停);紧急全平始终可用。 +
  • + ) : null} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index da89e6e..737293d 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -613,6 +613,25 @@ input { border: 1px solid var(--line); } +.btn.danger { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} + +.btn.danger:hover:not(:disabled) { + background: rgba(246, 70, 93, 0.12); +} + +.btn.btn-running, +.btn.btn-running:disabled { + background: rgba(14, 203, 129, 0.16); + color: #0ecb81; + border: 1px solid #0ecb81; + opacity: 1; + cursor: default; +} + .btn.block { width: 100%; }