diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index b085a57..c154bb8 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -76,10 +76,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true # 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true -# Binance API Key(需开通合约,万向划转等权限) -BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY -# Binance API Secret -BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET +# Binance API(仅服务器 .env 配置;新机保持为空,填真钥后 pm2 restart --update-env;勿用占位符以免鉴权狂打) +BINANCE_API_KEY= +BINANCE_API_SECRET= # 保证金模式:cross=全仓,isolated=逐仓 BINANCE_MARGIN_MODE=cross # 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index fb1da1f..c9f529a 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -35,6 +35,11 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from lib.paths import common_static_dir +from lib.exchange.api_credentials_lib import ( + is_exchange_auth_error, + load_markets_public_fallback, + normalize_api_credential, +) from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_review_lib import ( build_journal_ai_chart_path, @@ -347,8 +352,8 @@ def _resolve_app_tz(): APP_TZ = _resolve_app_tz() LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" -BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() -BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() +BINANCE_API_KEY = normalize_api_credential(os.getenv("BINANCE_API_KEY")) +BINANCE_API_SECRET = normalize_api_credential(os.getenv("BINANCE_API_SECRET")) BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() # hedge=双向持仓(需 positionSide);oneway / single=单向持仓 _raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() @@ -490,6 +495,8 @@ if BINANCE_API_KEY and BINANCE_API_SECRET: exchange.apiKey = BINANCE_API_KEY exchange.secret = BINANCE_API_SECRET MARKETS_LOADED = False +# 鉴权失败后停止私有 API(资金/持仓),避免坏钥反复请求;尤其 Gate 易封 IP +EXCHANGE_AUTH_DISABLED_MSG = "" ACCOUNT_BALANCE_CACHE = { "updated_at": 0.0, "funding_usdt": None, @@ -2859,6 +2866,8 @@ def enrich_order_item(raw_item, current_capital): def ensure_exchange_live_ready(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False, EXCHANGE_AUTH_DISABLED_MSG if not LIVE_TRADING_ENABLED: return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" if not (BINANCE_API_KEY and BINANCE_API_SECRET): @@ -2893,9 +2902,23 @@ def order_row_key_signal_type(row): def exchange_private_api_configured(): """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" + if EXCHANGE_AUTH_DISABLED_MSG: + return False return bool(BINANCE_API_KEY and BINANCE_API_SECRET) +def _disable_private_api_after_auth_error(exc): + global EXCHANGE_AUTH_DISABLED_MSG, BINANCE_API_KEY, BINANCE_API_SECRET + from lib.exchange.api_credentials_lib import strip_ccxt_credentials + + strip_ccxt_credentials(exchange) + BINANCE_API_KEY = "" + BINANCE_API_SECRET = "" + EXCHANGE_AUTH_DISABLED_MSG = ( + f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}" + ) + + def _float_balance_field(val): if val is None or val == "": return None @@ -3178,11 +3201,15 @@ def get_exchange_capitals(force=False): return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] try: ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() - except Exception: + except Exception as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) ACCOUNT_BALANCE_CACHE["funding_usdt"] = None try: ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() - except Exception: + except Exception as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts @@ -3585,7 +3612,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: - exchange.load_markets(reload=force) + try: + exchange.load_markets(reload=force) + except Exception as e: + # 坏钥时立刻去掉签名再拉公开 markets,避免反复鉴权(尤其勿拖累同机 Gate) + if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)): + _disable_private_api_after_auth_error(e) + load_markets_public_fallback(exchange, reload=True) + else: + raise MARKETS_LOADED = True diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index 220a2c3..ac6c191 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -74,10 +74,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true # 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true -# Gate API Key(实盘) -GATE_API_KEY=REPLACE_WITH_GATE_API_KEY -# Gate API Secret(实盘) -GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET +# Gate API(仅服务器 .env 配置;新机保持为空,填真钥后重启;错误密钥反复请求易导致 Gate 封 IP) +GATE_API_KEY= +GATE_API_SECRET= # 保证金模式:cross=全仓,isolated=逐仓 GATE_TD_MODE=cross # 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 246acd4..5f7b423 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -35,6 +35,11 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from lib.paths import common_static_dir +from lib.exchange.api_credentials_lib import ( + is_exchange_auth_error, + load_markets_public_fallback, + normalize_api_credential, +) from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_review_lib import ( build_journal_ai_chart_path, @@ -346,8 +351,8 @@ def _resolve_app_tz(): APP_TZ = _resolve_app_tz() LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" -GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() -GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() +GATE_API_KEY = normalize_api_credential(os.getenv("GATE_API_KEY")) +GATE_API_SECRET = normalize_api_credential(os.getenv("GATE_API_SECRET")) GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() # 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) @@ -478,6 +483,8 @@ if GATE_API_KEY and GATE_API_SECRET: exchange.apiKey = GATE_API_KEY exchange.secret = GATE_API_SECRET MARKETS_LOADED = False +# 鉴权失败后停止私有 API,避免坏钥反复签名;Gate 尤其易封 IP +EXCHANGE_AUTH_DISABLED_MSG = "" ACCOUNT_BALANCE_CACHE = { "updated_at": 0.0, "funding_usdt": None, @@ -2547,6 +2554,8 @@ def enrich_order_item(raw_item, current_capital): def ensure_exchange_live_ready(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False, EXCHANGE_AUTH_DISABLED_MSG if not LIVE_TRADING_ENABLED: return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" if not (GATE_API_KEY and GATE_API_SECRET): @@ -2581,9 +2590,23 @@ def order_row_key_signal_type(row): def exchange_private_api_configured(): """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" + if EXCHANGE_AUTH_DISABLED_MSG: + return False return bool(GATE_API_KEY and GATE_API_SECRET) +def _disable_private_api_after_auth_error(exc): + global EXCHANGE_AUTH_DISABLED_MSG, GATE_API_KEY, GATE_API_SECRET + from lib.exchange.api_credentials_lib import strip_ccxt_credentials + + strip_ccxt_credentials(exchange) + GATE_API_KEY = "" + GATE_API_SECRET = "" + EXCHANGE_AUTH_DISABLED_MSG = ( + f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启;勿反复试错以免 Gate 封 IP): {exc}" + ) + + def _extract_usdt_total(balance): usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {} @@ -2841,11 +2864,15 @@ def get_exchange_capitals(force=False): return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] try: ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() - except Exception: + except Exception as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) ACCOUNT_BALANCE_CACHE["funding_usdt"] = None try: ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) - except Exception: + except Exception as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts @@ -3345,7 +3372,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: - exchange.load_markets(reload=force) + try: + exchange.load_markets(reload=force) + except Exception as e: + # Gate 对无效签名/坏钥敏感,失败后立即改公开 markets,勿反复带钥请求 + if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)): + _disable_private_api_after_auth_error(e) + load_markets_public_fallback(exchange, reload=True) + else: + raise MARKETS_LOADED = True diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 5975ff1..4d504c8 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -80,12 +80,13 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true # 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true # ============================================================================= -# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2) +# OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示) +# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理) # 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填 # ============================================================================= -OKX_API_KEY=REPLACE_WITH_OKX_API_KEY -OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET -OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE +OKX_API_KEY= +OKX_API_SECRET= +OKX_API_PASSPHRASE= # 保证金模式:cross=全仓,isolated=逐仓 OKX_TD_MODE=cross # 持仓模式:hedge=双向持仓,net=单向净持仓 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index d565ac5..de1657e 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -35,6 +35,11 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from lib.paths import common_static_dir +from lib.exchange.api_credentials_lib import ( + is_exchange_auth_error, + load_markets_public_fallback, + normalize_api_credential, +) from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_review_lib import ( build_journal_ai_chart_path, @@ -360,9 +365,9 @@ def _promote_legacy_options_api_keys() -> None: _promote_legacy_options_api_keys() -OKX_API_KEY = os.getenv("OKX_API_KEY", "") -OKX_API_SECRET = os.getenv("OKX_API_SECRET", "") -OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") +OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY")) +OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET")) +OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE")) OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on") OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() @@ -506,6 +511,7 @@ if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: exchange_options.password = OKX_API_PASSPHRASE MARKETS_LOADED = False +EXCHANGE_AUTH_DISABLED_MSG = "" ACCOUNT_BALANCE_CACHE = { "updated_at": 0.0, "funding_usdt": None, @@ -2485,6 +2491,8 @@ def enrich_order_item(raw_item, current_capital): def ensure_okx_live_ready(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False, EXCHANGE_AUTH_DISABLED_MSG if not LIVE_TRADING_ENABLED: return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): @@ -2517,6 +2525,23 @@ def order_row_key_signal_type(row): return None +def _disable_private_api_after_auth_error(exc): + global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE + from lib.exchange.api_credentials_lib import strip_ccxt_credentials + + strip_ccxt_credentials(exchange) + try: + strip_ccxt_credentials(exchange_options) + except Exception: + pass + OKX_API_KEY = "" + OKX_API_SECRET = "" + OKX_API_PASSPHRASE = "" + EXCHANGE_AUTH_DISABLED_MSG = ( + f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}" + ) + + def _extract_usdt_total(balance): usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {} @@ -2637,8 +2662,9 @@ def get_exchange_capitals(force=False): ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts - except Exception: - pass + except Exception as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] @@ -2869,7 +2895,14 @@ def build_okx_order_params(direction, reduce_only=False): def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: - exchange.load_markets(reload=force) + try: + exchange.load_markets(reload=force) + except Exception as e: + if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)): + _disable_private_api_after_auth_error(e) + load_markets_public_fallback(exchange, reload=True) + else: + raise MARKETS_LOADED = True @@ -2985,6 +3018,8 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_ def exchange_private_api_configured(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) diff --git a/deploy/README.md b/deploy/README.md index 4be8ede..1b5befb 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -40,9 +40,9 @@ bash /opt/crypto_monitor/deploy/manage.sh - 登录账号: **admin** - 登录密码: **admin123** -- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置** +- 浏览器配置: 各所 **env 配置**(风控等) + 中控 **系统设置**;**交易所 API 仅服务器 `.env` 手改**(新机默认为空) -**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成. +**无需 SSH 编辑 `.env` 填通信/登录类密钥**;交易所 API 须在服务器写入各所 `.env`(新机为空). | 地址 | 端口 | |------|------| diff --git a/docs/env配置说明.md b/docs/env配置说明.md index affa601..d81d47a 100644 --- a/docs/env配置说明.md +++ b/docs/env配置说明.md @@ -14,6 +14,7 @@ | **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | | **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | | **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | +| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` | | **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | | **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | @@ -61,15 +62,14 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本 | 中文名 | 说明 | 重启 | |--------|------|------| | 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | -| API Key | 账户 API Key(永续+期权共用) | 需重启 | -| API Secret | 账户 API Secret | 需重启 | -| API Passphrase | 仅 OKX 显示 | 需重启 | | 保证金模式 | 全仓 / 逐仓 | 需重启 | | 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | | 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | | 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | | 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 | +**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP. + **本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥. --- @@ -196,6 +196,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT - 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG` - 数据:`DB_PATH`,`UPLOAD_DIR` +- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空) - 关键位门控:全部 `KEY_*`,`KLINE_*` - 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*` - 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` 等 @@ -212,7 +213,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT | 能力 | env 配置 | 系统设置 | 中控系统设置 | |------|----------|----------|--------------| | 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | -| 交易所 API | ✅(各所自配) | ❌ | ❌ | +| 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ | | AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | | 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | | 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | diff --git a/docs/中控AI与密钥配置.md b/docs/中控AI与密钥配置.md index 23cf8a7..563ff0c 100644 --- a/docs/中控AI与密钥配置.md +++ b/docs/中控AI与密钥配置.md @@ -75,7 +75,7 @@ python3 scripts/bootstrap_deploy_secrets.py ## 4. 实例 env 配置页变更 -三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配. +三所 **env 配置** 页已 **移除「AI 复盘」卡片**.企业微信、交易执行等仍各所自配;**交易所 API 仅服务器 `.env`**,前端不再展示. 实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入. diff --git a/docs/系统设置说明.md b/docs/系统设置说明.md index f7d4857..d2898a8 100644 --- a/docs/系统设置说明.md +++ b/docs/系统设置说明.md @@ -66,7 +66,7 @@ | 网页登录密码 | ✅ | 本区块 | | 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | | 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | -| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) | +| 交易所 API | ❌ | **仅服务器** 各所 `.env`(`*_API_KEY` 等;前端 env 页已移除) | | AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | ### 操作流程 diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 1501925..ea58a5a 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -19,10 +19,7 @@ from lib.env.env_schema import ( # 各所「交易所与实盘」字段(顺序即页面顺序) _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { "okx": [ - ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), - ("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"), - ("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"), - ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"), + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"), ("OKX_TD_MODE", "保证金模式", ""), ("OKX_POS_MODE", "持仓模式", ""), ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), @@ -34,17 +31,13 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { ), ], "binance": [ - ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), - ("BINANCE_API_KEY", "API Key", "永续子账户"), - ("BINANCE_API_SECRET", "API Secret", "永续子账户"), + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"), ("BINANCE_MARGIN_MODE", "保证金模式", ""), ("BINANCE_POSITION_MODE", "持仓模式", ""), ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], "gate": [ - ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), - ("GATE_API_KEY", "API Key", "永续子账户"), - ("GATE_API_SECRET", "API Secret", "永续子账户"), + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"), ("GATE_TD_MODE", "保证金模式", ""), ("GATE_POS_MODE", "持仓模式", ""), ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), diff --git a/lib/exchange/api_credentials_lib.py b/lib/exchange/api_credentials_lib.py new file mode 100644 index 0000000..82d754f --- /dev/null +++ b/lib/exchange/api_credentials_lib.py @@ -0,0 +1,94 @@ +"""交易所 API 凭证规范化. + +新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且 +(尤其 Gate)反复签名请求易触发 IP 封禁. +""" +from __future__ import annotations + +from typing import Any, Optional + + +_PLACEHOLDER_EXACT = frozenset( + { + "你的密钥", + "your-api-key", + "your_api_key", + "your-api-secret", + "your_api_secret", + "todo", + "xxx", + "changeme", + } +) + + +def normalize_api_credential(value: Optional[str]) -> str: + """去空白;空/占位符一律视为未配置.""" + s = (value or "").strip().strip('"').strip("'") + if not s: + return "" + upper = s.upper() + if upper.startswith("REPLACE_WITH"): + return "" + if upper.startswith("CHANGE_TO"): + return "" + if s.lower() in _PLACEHOLDER_EXACT: + return "" + return s + + +def credentials_configured(*parts: Optional[str]) -> bool: + return all(bool(normalize_api_credential(p)) for p in parts) + + +def is_exchange_auth_error(exc: BaseException) -> bool: + """鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP).""" + name = type(exc).__name__ + if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"): + return True + msg = str(exc) + markers = ( + "Invalid Api-Key", + "Invalid API-key", + "Invalid API Key", + "INVALID_KEY", + "Invalid key", + "API key is invalid", + "api key not found", + "Signature", + "INVALID_SIGNATURE", + "401", + "-2008", + "-2014", + "-2015", + "10003", # Gate: invalid key often + "INVALID_KEY", + ) + low = msg.lower() + if "api" in low and ("key" in low or "sign" in low) and ( + "invalid" in low or "incorrect" in low or "not found" in low + ): + return True + return any(m in msg for m in markers) + + +def strip_ccxt_credentials(exchange: Any) -> None: + """内存中清空密钥,后续只走公开接口,避免继续带坏钥签名.""" + try: + exchange.apiKey = "" + except Exception: + pass + try: + exchange.secret = "" + except Exception: + pass + try: + exchange.password = "" + except Exception: + pass + + +def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None: + """鉴权失败后去掉密钥再拉公开 markets(最多再请求一次).""" + strip_ccxt_credentials(exchange) + exchange.load_markets(reload=reload) diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 304f1d2..7437c83 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -37,6 +37,11 @@ from lib.hub.hub_position_metrics import ( parse_position_unrealized_pnl, resolve_position_display_upnl, ) +from lib.exchange.api_credentials_lib import ( + is_exchange_auth_error, + normalize_api_credential, + strip_ccxt_credentials, +) import ccxt from fastapi import FastAPI, Header, HTTPException, Request @@ -86,12 +91,31 @@ GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None) _ccxt_ex: Any = None _markets_loaded = False +# 鉴权失败冷却:中控会轮询 /status;坏钥时若持续签名请求,Gate 易封 IP +_AUTH_FAIL_UNTIL = 0.0 +_AUTH_FAIL_MSG = "" +_AUTH_COOLDOWN_SEC = 600 def _socks_proxy_url(prefix: str) -> str: return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip() +def _raise_if_auth_cooling() -> None: + if time.time() < _AUTH_FAIL_UNTIL and _AUTH_FAIL_MSG: + raise RuntimeError(_AUTH_FAIL_MSG) + + +def _mark_auth_failure(exc: BaseException) -> None: + global _AUTH_FAIL_UNTIL, _AUTH_FAIL_MSG, _ccxt_ex, _markets_loaded + _AUTH_FAIL_MSG = f"交易所鉴权失败(已暂停签名请求 {_AUTH_COOLDOWN_SEC}s,避免封 IP): {exc}" + _AUTH_FAIL_UNTIL = time.time() + _AUTH_COOLDOWN_SEC + if _ccxt_ex is not None: + strip_ccxt_credentials(_ccxt_ex) + _ccxt_ex = None + _markets_loaded = False + + def _http_https_proxy(prefix: str) -> dict[str, str] | None: http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip() https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip() @@ -110,11 +134,12 @@ def _attach_proxies(ex: Any, prefix: str) -> None: def _make_exchange() -> Any: + _raise_if_auth_cooling() if EXCHANGE_KIND == "binance": - key = (os.getenv("BINANCE_API_KEY") or "").strip() - secret = (os.getenv("BINANCE_API_SECRET") or "").strip() + key = normalize_api_credential(os.getenv("BINANCE_API_KEY")) + secret = normalize_api_credential(os.getenv("BINANCE_API_SECRET")) if not key or not secret: - raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET") + raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET(请在服务器 .env 配置真密钥)") ex = ccxt.binance( { "apiKey": key, @@ -133,11 +158,11 @@ def _make_exchange() -> Any: return ex if EXCHANGE_KIND == "okx": - key = (os.getenv("OKX_API_KEY") or "").strip() - secret = (os.getenv("OKX_API_SECRET") or "").strip() - password = (os.getenv("OKX_API_PASSPHRASE") or "").strip() + key = normalize_api_credential(os.getenv("OKX_API_KEY")) + secret = normalize_api_credential(os.getenv("OKX_API_SECRET")) + password = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE")) if not key or not secret or not password: - raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE") + raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE(请在服务器 .env 配置真密钥)") ex = ccxt.okx( { "apiKey": key, @@ -154,10 +179,10 @@ def _make_exchange() -> Any: return ex # gate - key = (os.getenv("GATE_API_KEY") or "").strip() - secret = (os.getenv("GATE_API_SECRET") or "").strip() + key = normalize_api_credential(os.getenv("GATE_API_KEY")) + secret = normalize_api_credential(os.getenv("GATE_API_SECRET")) if not key or not secret: - raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET") + raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET(请在服务器 .env 配置真密钥)") from lib.exchange.gate_ccxt_lib import gate_ccxt_class ex = gate_ccxt_class()( @@ -177,6 +202,7 @@ def _make_exchange() -> Any: def get_exchange() -> Any: global _ccxt_ex + _raise_if_auth_cooling() if _ccxt_ex is None: _ccxt_ex = _make_exchange() return _ccxt_ex @@ -184,9 +210,17 @@ def get_exchange() -> Any: def _ensure_markets() -> None: global _markets_loaded - if not _markets_loaded: + if _markets_loaded: + return + _raise_if_auth_cooling() + try: get_exchange().load_markets() _markets_loaded = True + except Exception as e: + if is_exchange_auth_error(e): + _mark_auth_failure(e) + raise RuntimeError(_AUTH_FAIL_MSG) from e + raise def _check_token(x_control_token: str | None) -> None: @@ -572,8 +606,20 @@ def _status_inner(x_control_token: str | None) -> Any: u = bal.get("USDT") or {} if isinstance(u, dict) and u.get("total") is not None: balance_usdt = _finite_or_none(u["total"]) - except Exception: - pass + except Exception as e: + if is_exchange_auth_error(e): + _mark_auth_failure(e) + return JSONResponse( + { + "ok": False, + "error": _AUTH_FAIL_MSG, + "exchange": EXCHANGE_KIND, + "balance_usdt": None, + "positions": [], + "total_unrealized_pnl": None, + }, + status_code=200, + ) positions_out: list[dict[str, Any]] = [] total_upnl = 0.0 @@ -587,6 +633,19 @@ def _status_inner(x_control_token: str | None) -> Any: else: raw = ex.fetch_positions() or [] except Exception as e: + if is_exchange_auth_error(e): + _mark_auth_failure(e) + return JSONResponse( + { + "ok": False, + "error": _AUTH_FAIL_MSG, + "exchange": EXCHANGE_KIND, + "balance_usdt": balance_usdt, + "positions": [], + "total_unrealized_pnl": None, + }, + status_code=200, + ) return JSONResponse( { "ok": False, diff --git a/tests/test_api_credentials_lib.py b/tests/test_api_credentials_lib.py new file mode 100644 index 0000000..aa44cb1 --- /dev/null +++ b/tests/test_api_credentials_lib.py @@ -0,0 +1,36 @@ +import unittest + +from lib.exchange.api_credentials_lib import ( + credentials_configured, + is_exchange_auth_error, + normalize_api_credential, +) + + +class TestNormalizeApiCredential(unittest.TestCase): + def test_empty_and_placeholder(self): + self.assertEqual(normalize_api_credential(""), "") + self.assertEqual(normalize_api_credential(None), "") + self.assertEqual(normalize_api_credential(" "), "") + self.assertEqual(normalize_api_credential("REPLACE_WITH_GATE_API_KEY"), "") + self.assertEqual(normalize_api_credential("CHANGE_TO_LONG_RANDOM_SECRET"), "") + self.assertEqual(normalize_api_credential("你的密钥"), "") + + def test_real_key_kept(self): + self.assertEqual(normalize_api_credential(" real-key-value "), "real-key-value") + self.assertTrue(credentials_configured("abc", "def")) + self.assertFalse(credentials_configured("REPLACE_WITH_X", "secret")) + + +class TestAuthErrorDetect(unittest.TestCase): + def test_binance_invalid_key(self): + class AuthenticationError(Exception): + pass + + self.assertTrue(is_exchange_auth_error(AuthenticationError('binance {"code":-2008,"msg":"Invalid Api-Key ID."}'))) + self.assertTrue(is_exchange_auth_error(Exception("gate INVALID_KEY"))) + self.assertFalse(is_exchange_auth_error(Exception("rate limit exceeded"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_display_env_settings.py b/tests/test_instance_display_env_settings.py index 5a24f42..19619c6 100644 --- a/tests/test_instance_display_env_settings.py +++ b/tests/test_instance_display_env_settings.py @@ -125,7 +125,10 @@ class TestEnvSchema(unittest.TestCase): self.assertTrue(by_key["OKX_SHOW_PERP_FUNDS"].get("hot_reload")) self.assertNotIn("OKX_OPTIONS_API_KEY", by_key) self.assertNotIn("OKX_SUB_ACCOUNT_NAME", by_key) - self.assertEqual(by_key["OKX_API_KEY"]["note"], "账户 API(永续+期权共用)") + self.assertNotIn("OKX_API_KEY", by_key) + self.assertNotIn("OKX_API_SECRET", by_key) + self.assertNotIn("OKX_API_PASSPHRASE", by_key) + self.assertIn("LIVE_TRADING_ENABLED", by_key) groups_v = [{"title": "t", "fields": [by_key["TRADE_DIRECTION"]]}] clean, errors = validate_env_updates(groups_v, {"TRADE_DIRECTION": "long_only"})