diff --git a/backend/app/plugins/stocksdk/bridge.py b/backend/app/plugins/stocksdk/bridge.py index d18ce91..7e841a8 100644 --- a/backend/app/plugins/stocksdk/bridge.py +++ b/backend/app/plugins/stocksdk/bridge.py @@ -13,6 +13,7 @@ import logging import os import shutil import subprocess +import time from pathlib import Path logger = logging.getLogger(__name__) @@ -47,6 +48,9 @@ def run_job(job: dict, timeout: int = DEFAULT_TIMEOUT) -> dict: raise StockSDKBridgeError(f"桥接脚本缺失: {_BRIDGE_MJS}") payload = json.dumps(job, ensure_ascii=False) + op = job.get("op") + _t0 = time.perf_counter() + logger.info("stock-sdk 桥接开始 (op=%s, timeout=%ss)", op, timeout) try: proc = subprocess.run( [node, str(_BRIDGE_MJS)], @@ -59,9 +63,12 @@ def run_job(job: dict, timeout: int = DEFAULT_TIMEOUT) -> dict: cwd=str(_HERE), ) except subprocess.TimeoutExpired as e: - raise StockSDKBridgeError(f"stock-sdk 桥接超时(op={job.get('op')}, {timeout}s)") from e + logger.warning("stock-sdk 桥接超时 (op=%s, %ss)", op, timeout) + raise StockSDKBridgeError(f"stock-sdk 桥接超时(op={op}, {timeout}s)") from e except OSError as e: + logger.warning("stock-sdk 启动 node 失败 (op=%s): %s", op, e) raise StockSDKBridgeError(f"启动 node 失败: {e}") from e + logger.info("stock-sdk 桥接完成 (op=%s, %.2fs)", op, time.perf_counter() - _t0) if proc.returncode != 0: tail = (proc.stderr or proc.stdout or "").strip()[-800:] diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index d2b3946..4f002a3 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -477,11 +477,17 @@ class QuoteService: resp = [] if universes: + _u0 = time.perf_counter() + logger.info("拉取全市场行情 (universes=%s, SDK超时=30s×重试3)", universes) resp.extend(tf.quotes.get_by_universes(universes=universes) or []) + logger.info("全市场行情拉取完成: %d 条 (%.2fs)", len(resp), time.perf_counter() - _u0) if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core": - resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or []) + _i0 = time.perf_counter() + _core_syms = sorted(core_index_symbols) + resp.extend(tf.quotes.get(symbols=_core_syms) or []) + logger.info("核心指数行情拉取完成: %d 只 (%.2fs)", len(_core_syms), time.perf_counter() - _i0) except Exception as e: # noqa: BLE001 - logger.warning("行情拉取失败: %s", e) + logger.warning("行情拉取失败 (%.2fs): %s", time.perf_counter() - t0, e) return if not resp: diff --git a/backend/app/tickflow/client.py b/backend/app/tickflow/client.py index d02b608..4c7a6b0 100644 --- a/backend/app/tickflow/client.py +++ b/backend/app/tickflow/client.py @@ -10,12 +10,18 @@ """ from __future__ import annotations +import logging import os from tickflow import AsyncTickFlow, TickFlow from app import secrets_store +logger = logging.getLogger(__name__) + +# SDK 默认超时配置 (见 tickflow/_base_client.py): timeout=30s, max_retries=3。 +# 单次请求最坏 4×30s + 退避 ≈ 127s。日志中标注此值, 便于在卡死时对照耗时。 + _sync_client: TickFlow | None = None _async_client: AsyncTickFlow | None = None _paid_realtime_client: TickFlow | None = None @@ -55,8 +61,10 @@ def get_client() -> TickFlow: if _should_use_free_server(): # none/free 档:走 free-api 服务器(无 key 或免费 key 被 SDK 忽略) _sync_client = TickFlow.free() + logger.info("创建同步 SDK 客户端 (free-api, SDK超时=30s×重试3)") else: _sync_client = TickFlow(api_key=key, base_url=_base_url()) + logger.info("创建同步 SDK 客户端 (付费端点=%s, SDK超时=30s×重试3)", current_endpoint()) return _sync_client @@ -67,8 +75,10 @@ def get_async_client() -> AsyncTickFlow: key = secrets_store.get_tickflow_key() if _should_use_free_server(): _async_client = AsyncTickFlow.free() + logger.info("创建异步 SDK 客户端 (free-api, SDK超时=30s×重试3)") else: _async_client = AsyncTickFlow(api_key=key, base_url=_base_url()) + logger.info("创建异步 SDK 客户端 (付费端点=%s, SDK超时=30s×重试3)", current_endpoint()) return _async_client @@ -84,6 +94,7 @@ def get_paid_realtime_client() -> TickFlow | None: return None if _paid_realtime_client is None: _paid_realtime_client = TickFlow(api_key=key, base_url=_base_url()) + logger.info("创建实时行情 SDK 客户端 (付费端点=%s, SDK超时=30s×重试3)", current_endpoint()) return _paid_realtime_client diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 8f8dbcd..e7a7acb 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -123,6 +123,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]: # 探测专用客户端:强制走付费端点验证 key。 # base_url 用用户自定义端点(若已配置测速切换),否则默认 api.tickflow.org。 probe_base = _base_url() or PAID_ENDPOINT + logger.info("开始能力探测 (付费端点=%s, SDK默认超时=30s×重试3)", probe_base) tf = TickFlow(api_key=key, base_url=probe_base) available: dict[Cap, CapabilityLimits] = {} log: list[str] = [] @@ -131,6 +132,9 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]: transient_failed: set[Cap] = set() def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None: + # 分段耗时日志: 记录每个 cap 探测的开始/结束/耗时/结果, 便于定位卡死环节。 + _t0 = time.perf_counter() + logger.info("能力探测开始: %s", cap.value) try: _call_with_retry(fn) available[cap] = CapabilityLimits( @@ -138,8 +142,11 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]: batch=default_limits.get("batch"), subscribe=default_limits.get("subscribe"), ) + _elapsed = time.perf_counter() - _t0 log.append(f"✓ {cap}") + logger.info("能力探测完成: %s ✓ (%.2fs)", cap.value, _elapsed) except Exception as e: # noqa: BLE001 + _elapsed = time.perf_counter() - _t0 msg = str(e).lower() cls = e.__class__.__name__ # PermissionError 类名 / HTTP 403 / 中英文权限关键词都算"明确无权限" @@ -151,6 +158,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]: ) if is_perm_denied: log.append(f"✗ {cap}(无权限)") + logger.info("能力探测完成: %s ✗ 无权限 (%.2fs)", cap.value, _elapsed) elif _is_transient(e): # 仅**真瞬时**错误(超时/连接/5xx/429, 由 _is_transient 判定)才标记为疑似 — # 与探测重试用同一判据。否则一个消息未命中权限关键词的确定性失败 @@ -158,9 +166,11 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]: # 保护(保留旧付费档)反而掩盖真实的 Key 失效, 永不回落到 free-api。 transient_failed.add(cap) log.append(f"? {cap} (瞬时: {cls}: {e})") + logger.warning("能力探测瞬时失败: %s ? %s: %s (%.2fs)", cap.value, cls, e, _elapsed) else: # 非权限关键词、也非瞬时 → 视为该能力确实不可用(不保留、不重试保护) log.append(f"✗ {cap}({cls}: {e})") + logger.info("能力探测完成: %s ✗ %s: %s (%.2fs)", cap.value, cls, e, _elapsed) # 用各档默认上限作为占位(无 X-RateLimit-* 头时) # 取所有档的并集,逐 cap 试探 @@ -295,7 +305,9 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: # 有 API key — 真实探测 try: + _probe_t0 = time.perf_counter() capset, probe_log, transient_failed = _probe_real(tiers) + logger.info("能力探测全部完成, 总耗时 %.2fs", time.perf_counter() - _probe_t0) # 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert classified = _classify_tier(capset, tiers) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7a2f211..d2bd162 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -150,7 +150,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i {item.name} {fmtIndexPct(pct)} -