diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 5084745..08b1dae 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,3 +1,3 @@ """TickFlow Stock Panel backend.""" -__version__ = "0.1.35" +__version__ = "0.1.36" diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 34d708e..d0df268 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -4,7 +4,7 @@ from __future__ import annotations from fastapi import APIRouter from app import __version__ -from app.config import settings +from app.tickflow import client as tf_client from app.tickflow.policy import detect_capabilities, tier_label router = APIRouter() @@ -15,7 +15,8 @@ def health() -> dict: return { "status": "ok", "version": __version__, - "mode": "free" if settings.use_free_mode else "api_key", + # 三态: none(无key/无效) / free(免费key) / api_key(付费档) + "mode": tf_client.current_mode(), } diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 657f161..5f16834 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -69,12 +69,12 @@ class SwitchEndpointIn(BaseModel): def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict: """切换 TickFlow 端点并立即生效。 - endpoints.json 里的端点都是 Starter+ 付费端点,Free 模式无 key - 无法使用,故 Free 模式下禁止切换。 + 端点切换仅对付费档(starter+,走 api.tickflow.org)有意义; + none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。 """ - # Free 模式没有付费端点权限,禁止切换 - if tf_client.current_mode() == "free": - return {"ok": False, "error": "Free 模式无法切换端点,请先配置 API Key"} + # none/free 档没有付费端点权限,禁止切换 + if tf_client.current_mode() != "api_key": + return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"} url = req.url.strip().rstrip("/") if not url.startswith("https://"): @@ -95,44 +95,89 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict: def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict: """保存 TickFlow API Key 并立即重新探测能力。 - 端点联动:Free → Starter+ 时,Free 模式残留的 free-api 端点不可用于 - 付费 Key,故自动切到默认付费端点(api.tickflow.org)。 + 先探后存(关键改动,修复乱填 key 也会被持久化的问题): + 1. 临时用新 key 探测(付费端点),判定档位 + 2. 判定为 none(连单只日K都拿不到)→ key 无效:不存,清除已存的, + 返回 {ok: false, reason: "invalid"},前端提示「Key 无效」 + 3. 判定为 free(免费有效 key)→ 存 key,客户端切到 free-api 服务器 + 4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑) + + 端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用, + 故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。 """ + from app.tickflow.policy import ( + base_tier_name, is_invalid_key, + ) + key = req.api_key.strip() if not key: return {"ok": False, "error": "key empty"} - # 判断是否 Free → Starter+ 转换(此前无 key) - was_free = tf_client.current_mode() == "free" - updates: dict = {"tickflow_api_key": key} - if was_free: - # 自动切到默认端点;之前的残留自定义 URL 不再适用 - updates["tickflow_base_url"] = DEFAULT_PAID_ENDPOINT - - secrets_store.save(updates) + # ===== 1) 临时存 key + 重置客户端,让探测走付费端点 ===== + secrets_store.save({"tickflow_api_key": key}) tf_client.reset_clients() - # 立即重新探测 + # 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证) capset = detect_capabilities(force=True) request.app.state.capabilities = capset + # ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 ===== + if is_invalid_key() or base_tier_name() == "none": + # 无效 key:清除刚存的,避免乱填被持久化;退回 none 档 + secrets_store.clear("tickflow_api_key", "tickflow_base_url") + tf_client.reset_clients() + capset = detect_capabilities(force=True) + request.app.state.capabilities = capset + return { + "ok": False, + "reason": "invalid", + "error": "Key 无效或已过期,请检查后重试", + "mode": "none", + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "probe_log": [], + "capabilities_count": len(capset.all()), + } + + # ===== 3) free 档(免费有效 key)→ 存 key,切到 free-api 服务器 ===== + if base_tier_name() == "free": + # 免费档运行时走 free-api 服务器,清除付费端点的自定义配置 + secrets_store.clear("tickflow_base_url") + tf_client.reset_clients() + return { + "ok": True, + "tickflow_api_key_masked": secrets_store.mask(key), + "mode": "free", + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "probe_log": [], + "capabilities_count": len(capset.all()), + } + + # ===== 4) starter+ 付费档 → 确保走付费端点(现有逻辑) ===== + # 若之前是 none/free(无自定义付费端点),切到默认付费端点 + base = secrets_store.load().get("tickflow_base_url") + if not base: + secrets_store.save({"tickflow_base_url": DEFAULT_PAID_ENDPOINT}) + tf_client.reset_clients() + return { "ok": True, "tickflow_api_key_masked": secrets_store.mask(key), "mode": "api_key", "tier_label": tier_label(), "current_endpoint": tf_client.current_endpoint(), - "probe_log": probe_log(), + "probe_log": [], "capabilities_count": len(capset.all()), } @router.delete("/tickflow-key") def clear_tickflow_key(request: Request) -> dict: - """清除 Key,退回 Free 模式。 + """清除 Key,退回无档(none)。 - 同时清除 tickflow_base_url(测速切换的自定义端点),使"当前使用" - 回到默认节点 api.tickflow.org;SDK 则自动用 free() 取免费数据。 + 同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api + 服务器取历史日K;档位标签为 None(无档)。 """ secrets_store.clear("tickflow_api_key", "tickflow_base_url") tf_client.reset_clients() @@ -142,7 +187,7 @@ def clear_tickflow_key(request: Request) -> dict: return { "ok": True, - "mode": "free", + "mode": "none", "tier_label": tier_label(), "current_endpoint": tf_client.current_endpoint(), "capabilities_count": len(capset.all()), @@ -202,6 +247,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict: # ===== 偏好设置 ===== +def _realtime_allowed() -> bool: + """当前档位是否允许实时行情(none/free 不允许)。""" + from app.services.quote_service import QuoteService + return QuoteService.is_realtime_allowed() + + class MinuteSyncPrefs(BaseModel): minute_sync_enabled: bool minute_sync_days: int = 5 @@ -213,6 +264,7 @@ def get_preferences() -> dict: from app.services import preferences return { "realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(), + "realtime_allowed": _realtime_allowed(), "indices_nav_pinned": preferences.get_indices_nav_pinned(), "minute_sync_enabled": preferences.get_minute_sync_enabled(), "minute_sync_days": preferences.get_minute_sync_days(), @@ -315,19 +367,30 @@ class RealtimeQuotesPrefs(BaseModel): @router.put("/preferences/realtime-quotes") def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: - """保存全局实时行情开关。""" - from app.services import preferences - preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled}) + """保存全局实时行情开关。 - # 动态启停行情服务 + none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False, + 前端据此把开关置灰 / 回弹。 + """ + from app.services import preferences qs = getattr(request.app.state, "quote_service", None) + + allowed = qs.is_realtime_allowed() if qs else True + if req.realtime_quotes_enabled and not allowed: + # 当前档位不允许开启实时行情 — 强制关闭 + preferences.save({"realtime_quotes_enabled": False}) + if qs: + qs.disable() + return {"realtime_quotes_enabled": False, "realtime_allowed": False} + + preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled}) if qs: if req.realtime_quotes_enabled: qs.enable() else: qs.disable() - return {"realtime_quotes_enabled": req.realtime_quotes_enabled} + return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed} class IndicesNavPinnedPrefs(BaseModel): diff --git a/backend/app/main.py b/backend/app/main.py index 1894acc..e9dc5c0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline from app.services.quote_service import QuoteService +from app.tickflow import client as tf_client from app.tickflow.policy import detect_capabilities from app.tickflow.repository import DataStore, KlineRepository @@ -30,7 +31,7 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): logger.info( "TickFlow Stock Panel v%s starting (mode=%s)", - __version__, "free" if settings.use_free_mode else "api_key", + __version__, tf_client.current_mode(), ) # 数据层 @@ -197,6 +198,22 @@ app.include_router(signals.router) app.include_router(monitor_rules.router) app.include_router(alerts.router) + +# 能力门控异常 → 403(而非默认 500) +# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied; +# 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。 +from fastapi import Request +from fastapi.responses import JSONResponse +from app.tickflow.capabilities import CapabilityDenied + + +@app.exception_handler(CapabilityDenied) +async def capability_denied_handler(request: Request, exc: CapabilityDenied) -> JSONResponse: + return JSONResponse( + status_code=403, + content={"detail": str(exc), "suggestion": exc.suggestion}, + ) + # 生产期静态文件(前端 dist) _static = Path(settings.static_dir) if _static.exists(): diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 03d7a3e..aa39c7a 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -99,8 +99,15 @@ class QuoteService: self._save_enabled(False) logger.info("行情服务已停止") - def enable(self) -> None: - """开启自动行情 (不立即启动线程,等下一个交易时段)。""" + def enable(self) -> bool: + """开启自动行情 (不立即启动线程,等下一个交易时段)。 + + none/free 档无实时行情权限,拒绝开启并返回 False; + starter+ 正常启动。返回值表示是否真正开启。 + """ + if not self.is_realtime_allowed(): + logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限") + return False self._enabled = True self._save_enabled(True) if not self._running: @@ -117,8 +124,17 @@ class QuoteService: logger.info("行情服务已关闭") def boot_check(self) -> None: - """启动时检查 preferences,若 enabled 则自动启动。""" + """启动时检查 preferences,若 enabled 则自动启动。 + + none/free 档无实时行情权限:即使 preferences 标记为 enabled, + 也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。 + """ from app.services import preferences + if not self.is_realtime_allowed(): + if preferences.get_realtime_quotes_enabled(): + self._save_enabled(False) + logger.info("实时行情未启动:当前档位(none/free)无实时行情权限") + return if preferences.get_realtime_quotes_enabled(): self.start() @@ -182,6 +198,15 @@ class QuoteService: from app.tickflow.policy import tier_label return tier_label().split()[0].split("+")[0].strip().lower() + @classmethod + def is_realtime_allowed(cls) -> bool: + """当前档位是否允许使用实时行情。 + + none/free 档走 free-api 服务器,无实时行情权限 → 不允许; + starter+ 付费档走付费端点,有实时行情 → 允许。 + """ + return cls._current_tier() not in ("none", "free") + @classmethod def _tier_min_interval(cls) -> float: tier = cls._current_tier() diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 6b7a30e..879e6bd 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -97,6 +97,10 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8 elif capset.has(Cap.QUOTE_BY_SYMBOL): lim = capset.limits(Cap.QUOTE_BY_SYMBOL) batch_size = lim.batch if lim and lim.batch else 5 + else: + # 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情) + # 提前返回空,避免发起注定失败的请求 + return [] chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] diff --git a/backend/app/tickflow/client.py b/backend/app/tickflow/client.py index 4351bf4..5a0c087 100644 --- a/backend/app/tickflow/client.py +++ b/backend/app/tickflow/client.py @@ -2,6 +2,11 @@ 进程内单例;Key 来源(优先级):secrets.json > .env。 用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。 + +5 档体系下服务器归属: + - none 档(无 key / 无效 key) → TickFlow.free()(free-api 服务器) + - free 档(免费有效 key) → TickFlow.free()(key 被 SDK 忽略,运行时走 free-api) + - starter/pro/expert(付费 key) → TickFlow(api_key=key, base_url) """ from __future__ import annotations @@ -15,6 +20,27 @@ _sync_client: TickFlow | None = None _async_client: AsyncTickFlow | None = None +# ===== 服务器归属判定 ===== + +# free-api 服务器默认节点(SDK 默认值),none/free 档运行时走这里。 +FREE_ENDPOINT = "https://free-api.tickflow.org" +# 付费端点默认节点(starter+ 运行时走这里,也是端点切换的默认值)。 +PAID_ENDPOINT = "https://api.tickflow.org" + + +def _should_use_free_server() -> bool: + """是否应走 free-api 服务器。 + + 判定依据:无 key,或当前档位为 none/free。 + 付费档(starter+)走付费端点。 + """ + if not secrets_store.get_tickflow_key(): + return True + # 有 key 时按探测出的档位判定(避免读 capabilities.json 在首次启动前未生成的边界) + from app.tickflow.policy import base_tier_name + return base_tier_name() in ("none", "free") + + def _base_url() -> str | None: """从 secrets.json 读取用户自定义端点,没有则返回 None(用 SDK 默认)。""" return secrets_store.load().get("tickflow_base_url") or None @@ -25,8 +51,8 @@ def get_client() -> TickFlow: global _sync_client if _sync_client is None: key = secrets_store.get_tickflow_key() - if not key: - # Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api + if _should_use_free_server(): + # none/free 档:走 free-api 服务器(无 key 或免费 key 被 SDK 忽略) _sync_client = TickFlow.free() else: _sync_client = TickFlow(api_key=key, base_url=_base_url()) @@ -38,8 +64,7 @@ def get_async_client() -> AsyncTickFlow: global _async_client if _async_client is None: key = secrets_store.get_tickflow_key() - if not key: - # Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api + if _should_use_free_server(): _async_client = AsyncTickFlow.free() else: _async_client = AsyncTickFlow(api_key=key, base_url=_base_url()) @@ -54,19 +79,31 @@ def reset_clients() -> None: def current_mode() -> str: - """供 UI 显示当前模式。""" - return "api_key" if secrets_store.get_tickflow_key() else "free" + """供 UI 显示当前模式。三态: + + - "none" : 无 key / 无效 key(走 free-api,仅历史日K) + - "free" : 免费有效 key(走 free-api,仅历史日K) + - "api_key" : 付费 key(starter+,走付费端点,有实时行情) + """ + if not secrets_store.get_tickflow_key(): + return "none" + from app.tickflow.policy import base_tier_name + tier = base_tier_name() + if tier in ("none", "free"): + return "free" if tier == "free" else "none" + return "api_key" def current_endpoint() -> str: """返回当前显示用的端点 URL(对应 endpoints.json 列表项)。 - 注:SDK 的 TickFlow.free() 内部实际走 free-api,但 UI 显示统一用默认 - 节点(api.tickflow.org),使"当前使用"始终对得上端点列表里的某一项。 + - none/free 档:显示 free-api 服务器节点 + - 付费档:显示用户自定义端点(测速切换后)或默认付费节点 api.tickflow.org """ + if _should_use_free_server(): + return FREE_ENDPOINT # 自定义端点(付费模式测速切换后):优先返回 base = _base_url() if base: return base.rstrip("/") - # Free 模式或未自定义:统一显示默认节点 - return "https://api.tickflow.org" + return PAID_ENDPOINT diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 9de3857..33912a5 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import logging import time +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -29,7 +30,9 @@ _CAPSET_CACHE_FILE = "capabilities.json" # 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。 # v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量) # v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测) -_CACHE_SCHEMA_VERSION = 3 +# v4: 5 档重构 —— 新增 none 档(无key/无效key),free 档重定义(走 free-api 服务器, +# 仅历史日K)。判定改为复权因子分水岭:_classify_tier 接管档位判定。 +_CACHE_SCHEMA_VERSION = 4 # 探测用最小代价请求:挑流通性最好的 1 只标的试 _PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市 @@ -247,27 +250,36 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: tiers = _load_tiers_yaml() if settings.use_free_mode: - capset = _tier_to_capset(tiers["free"]) - label, missing, extras = _compute_label_and_missing(capset, tiers) - _persist(capset, label, log=["Free 模式(无 API Key)"], missing=missing, extras=extras) + # 无 key —— 归 none 档(走 free-api 服务器,仅历史日K) + capset = _tier_to_capset(tiers["none"]) + _persist(capset, "None", log=["无 API Key(无档 · free-api 服务器)"], missing=[], extras=[]) return capset # 有 API key — 真实探测 try: capset, probe_log = _probe_real(tiers) - if not capset.all(): - logger.warning("probe returned no caps; falling back to free baseline") + # 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert + classified = _classify_tier(capset, tiers) + if classified.is_invalid: + # 无效 key(连单只日K都拿不到):归 none 档,标记要求清除 key + capset = _tier_to_capset(tiers["none"]) + probe_log.append("⚠ Key 无效(单只日K也无法获取),判定为无档") + _persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True) + return capset + if classified.is_free: + # 免费有效 key:能力按 free 档(= none 档能力,走 free-api 服务器) capset = _tier_to_capset(tiers["free"]) - probe_log.append("⚠ 所有探测均失败,降级为 Free 占位") + _persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[]) + return capset + # 付费档(starter+) — 探测出的能力即为真实可用 label, missing, extras = _compute_label_and_missing(capset, tiers) - # 探测时 limits 用了"任意档默认值",现在判档完成,用真实档位的 limits 覆盖 capset = _override_limits_with_detected_tier(capset, label, tiers) _persist(capset, label, log=probe_log, missing=missing, extras=extras) return capset except Exception as e: - logger.exception("detect_capabilities failed; using free baseline: %s", e) - capset = _tier_to_capset(tiers["free"]) - _persist(capset, "Free(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[]) + logger.exception("detect_capabilities failed; using none baseline: %s", e) + capset = _tier_to_capset(tiers["none"]) + _persist(capset, "None(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[]) return capset @@ -280,9 +292,55 @@ TIER_SIGNATURES: dict[str, set[Cap]] = { Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH}, "starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH, Cap.ADJ_FACTOR, Cap.QUOTE_POOL}, - # free 不需 signature — 默认兜底 + # free / none 不需 signature — 由 _classify_tier 的分水岭逻辑判定 } + +@dataclass(slots=True, frozen=True) +class TierClassification: + """档位判定结果。 + + 判定依据是"复权因子分水岭": + - 连单只日K都没有 → 无效 key(is_invalid),归 none 档 + - 有单只日K、无复权因子 → 免费 key(is_free) + - 有复权因子 → 付费档(starter+),具体档位由 signature 决定 + """ + + tier: str # "none" / "free" / "starter" / "pro" / "expert" + is_invalid: bool # 无效 key(连单只日K都拿不到) + is_free: bool # 免费有效 key(有日K、无复权因子) + + +def _classify_tier(capset: CapabilitySet, tiers: dict) -> TierClassification: + """根据探测出的能力集判定档位。 + + 分水岭是 KLINE_DAILY_BY_SYMBOL(单只日K)与 ADJ_FACTOR(复权因子): + - 无单只日K → none(无效 key) + - 有日K无复权 → free(免费 key) + - 有复权因子 → 走 signature 判定 starter/pro/expert + """ + held = set(capset.all().keys()) + + # 1) 连单只日K都没有 → 无效 key + if Cap.KLINE_DAILY_BY_SYMBOL not in held: + return TierClassification(tier="none", is_invalid=True, is_free=False) + + # 2) 有日K但无复权因子 → 免费 key + if Cap.ADJ_FACTOR not in held: + return TierClassification(tier="free", is_invalid=False, is_free=True) + + # 3) 有复权因子 → 付费档,按 signature 自上而下判定 + if held & TIER_SIGNATURES["expert"]: + base = "expert" + elif held & TIER_SIGNATURES["pro"]: + base = "pro" + elif held & TIER_SIGNATURES["starter"]: + base = "starter" + else: + # 有复权因子但无任何代表能力 — 兜底为 starter(复权本身是 starter 特征) + base = "starter" + return TierClassification(tier=base, is_invalid=False, is_free=False) + # 补丁友好命名(label 后缀用) _CAP_ALIASES: dict[Cap, str] = { Cap.KLINE_MINUTE_BATCH: "分钟K", @@ -395,6 +453,7 @@ def _persist( log: list[str] | None = None, missing: list[str] | None = None, extras: list[str] | None = None, + invalid_key: bool = False, ) -> None: settings.data_dir.mkdir(parents=True, exist_ok=True) cache_path = settings.data_dir / _CAPSET_CACHE_FILE @@ -405,6 +464,7 @@ def _persist( "probe_log": log or [], "missing_caps": missing or [], # 本档应有但未探测到 "extras_caps": extras or [], # 超出本档的额外能力 + "invalid_key": invalid_key, # 探测出的 key 无效(连单只日K都拿不到) } with cache_path.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) @@ -456,3 +516,24 @@ def extras_caps() -> list[str]: with cache_path.open(encoding="utf-8") as f: return json.load(f).get("extras_caps", []) return [] + + +def is_invalid_key() -> bool: + """最近一次探测是否判定 key 无效(连单只日K都拿不到)。 + + settings 层据此清除已存的 key,避免乱填的 key 被持久化。 + """ + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + return bool(json.load(f).get("invalid_key", False)) + return False + + +def base_tier_name() -> str: + """当前档位的基础名(小写): none / free / starter / pro / expert。 + + 供 client 层判断"是否走 free-api 服务器"(none/free → free 服务器)。 + """ + label = tier_label() + return label.split()[0].split("+")[0].strip().lower() diff --git a/frontend/package.json b/frontend/package.json index a499d57..21c3e5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.35", + "version": "0.1.36", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 820b62a..debe33a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -16,6 +16,7 @@ import { useToggleRealtimeQuotes, } from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' +import { tierRank } from '@/lib/capability-labels' import { Star, ScanSearch, @@ -134,7 +135,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i // ===== 档位卡片 ===== function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { const base = label.split(' ')[0].split('+')[0].toLowerCase() - const isFree = base === 'free' || !label + const isNone = base === 'none' const tierConfig: Record = { + none: { + desc: '未配置 Key · 仅历史日K', + tagBg: { background: 'rgba(113,113,122,0.15)' }, + dotStyle: { background: '#52525b' }, + labelTextStyle: { color: '#71717a' }, + }, free: { desc: '基础日K · 单股查询', tagBg: { background: 'rgba(113,113,122,0.3)' }, @@ -168,7 +175,9 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { }, } - const t = tierConfig[base] || tierConfig.free + const t = tierConfig[base] || tierConfig.none + // none 档显示中文「无」,无 label 时显示「无档」 + const displayLabel = isNone ? '无' : (label || '无') return (
- {isFree && !hasKey ? '配置 Key 解锁更多能力' : t.desc} + {isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
- {label || 'Free'} + {displayLabel} @@ -271,7 +280,8 @@ export function Layout() { const toggleQuote = useToggleRealtimeQuotes() const isRunning = quoteStatus?.running ?? false const isTrading = quoteStatus?.is_trading_hours ?? false - const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free') + // none/free 档(无实时行情权限)→ rank < starter(1) + const isFreeTier = tierRank(caps?.label ?? '') < 1 // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒) const alertsTotalQuery = useQuery({ @@ -316,7 +326,7 @@ export function Layout() { queryKey: QK.capabilities, queryFn: api.capabilities, }) - if ((fresh.label ?? '').toLowerCase().startsWith('free')) return + if (tierRank(fresh.label ?? '') < 1) return } await toggleQuote.mutateAsync(enabled) // 仅在交易时段立即获取一次行情 @@ -356,7 +366,7 @@ export function Layout() { = { - instruments: { capKey: 'quote.by_symbol', tierReq: 'Free' }, + // 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力 + instruments: { capKey: '', tierReq: '' }, daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' }, adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' }, enriched: { capKey: '', tierReq: '' }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7bfa3eb..baddeed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -549,7 +549,7 @@ export interface EndpointManifest { } export interface SettingsState { - mode: 'free' | 'api_key' + mode: 'none' | 'free' | 'api_key' tickflow_api_key_masked: string has_tickflow_key: boolean tier_label: string @@ -568,6 +568,19 @@ export interface SettingsState { ai_daily_token_budget: number } +/** 保存 TickFlow Key 的响应(先探后存) */ +export interface SaveTickflowKeyResult { + ok: boolean + /** ok=false 且 key 无效时的原因标识,前端据此提示「Key 无效」 */ + reason?: 'invalid' + error?: string + mode?: 'none' | 'free' | 'api_key' + tier_label?: string + current_endpoint?: string + tickflow_api_key_masked?: string + capabilities_count?: number +} + export interface Preferences { realtime_quotes_enabled: boolean indices_nav_pinned: boolean @@ -609,7 +622,7 @@ export const api = { settings: () => request('/api/settings'), saveTickflowKey: (api_key: string) => - request('/api/settings/tickflow-key', { + request('/api/settings/tickflow-key', { method: 'POST', body: JSON.stringify({ api_key }), }), diff --git a/frontend/src/lib/capability-labels.tsx b/frontend/src/lib/capability-labels.tsx index c764723..0e4087e 100644 --- a/frontend/src/lib/capability-labels.tsx +++ b/frontend/src/lib/capability-labels.tsx @@ -16,7 +16,8 @@ export const CAP_LABELS: Record = { // 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。 // 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。 -export const TIER_RANK: Record = { free: 0, starter: 1, pro: 2, expert: 3 } +// none = 无档(无 key / 无效 key),低于 free,仅历史日K无实时行情。 +export const TIER_RANK: Record = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 } export const EXPERT_RANK = TIER_RANK.expert export function tierRank(label: string): number { @@ -37,6 +38,12 @@ export interface TierStyle { } const TIER_STYLE: Record = { + none: { + desc: '未配置 Key · 仅历史日K', + tagBg: { background: 'rgba(113,113,122,0.15)' }, + dotStyle: { background: '#52525b' }, + labelTextStyle: { color: '#71717a' }, + }, free: { desc: '基础日K · 单股查询', tagBg: { background: 'rgba(113,113,122,0.3)' }, @@ -74,7 +81,7 @@ export function tierStyle(label: string): TierStyle { } /** 所有档位(有序, 供档位列表渲染) */ -export const ALL_TIERS = ['free', 'starter', 'pro', 'expert'] as const +export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const /** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */ export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } { @@ -85,12 +92,14 @@ export function tierTextStyle(label: string): { color?: string; background?: str export function TierTag({ label, className = '' }: { label: string; className?: string }) { const t = tierStyle(label) const base = tierBaseName(label) + // none 档显示中文「无」,其余档显示英文档名 + const display = base === 'none' ? '无' : base return ( - {base} + {display} ) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5adb9c9..92c87d0 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,12 +2,12 @@ import { useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { motion } from 'framer-motion' -import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer } from 'lucide-react' +import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer, ExternalLink, Gift } from 'lucide-react' import { DatePicker } from '@/components/DatePicker' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum, fmtPct } from '@/lib/format' -import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries' +import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { cn } from '@/lib/cn' @@ -480,9 +480,12 @@ export function Dashboard() { }) const data = overview.data const caps = useCapabilities() + const settings = useSettings() const hasDepth = !!caps.data?.capabilities?.['depth5.batch'] const sealedReady = !!data?.limit?.sealed_ready const isSealedDegrade = !hasDepth || !sealedReady + // none 档(无 key / 无效 key)→ 显示升级提示横幅 + const isNoKey = settings.data?.mode === 'none' // 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感 const handleRefresh = () => { @@ -520,6 +523,27 @@ export function Dashboard() { return (
+ {/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */} + {isNoKey && ( +
+ + + 当前未配置 API Key,实时行情、批量同步等能力不可用。 + + 前往 TickFlow 官网 + + + 免费注册(或填邀请码{' '} + V3KDKGXPEA + )即可领取免费 API Key,无需付费即可体验完整功能。 + +
+ )}
diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index 8c841b4..31a516d 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -732,7 +732,7 @@ export function Data() { {showEndpointTest && ( setShowEndpointTest(false)} diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx index 3eb7558..81880e5 100644 --- a/frontend/src/pages/Onboarding.tsx +++ b/frontend/src/pages/Onboarding.tsx @@ -230,16 +230,20 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () => const save = useMutation({ mutationFn: () => api.saveTickflowKey(keyInput.trim()), - onSuccess: () => { - setSaved(true) + onSuccess: (data) => { qc.invalidateQueries({ queryKey: QK.settings }) qc.invalidateQueries({ queryKey: QK.capabilities }) - // 保存成功后自动进入下一步看探测结果 - setTimeout(() => onNext(), 600) + if (data.ok) { + // 仅当 key 有效(被存储)时才进入下一步看探测结果 + setSaved(true) + setTimeout(() => onNext(), 600) + } + // ok=false(key 无效):不进入下一步,错误提示由 save.error / save.data 渲染 }, }) - const alreadyHasKey = settings.data?.has_tickflow_key + // 已配置 key —— 免费档或付费档都算(只要不是无档 none) + const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined return (
@@ -250,8 +254,8 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>

配置 TickFlow API Key

- Key 决定你能使用的数据范围。没有 Key 也能以 Free 模式试用基础功能; - 配置后可解锁概念行业、财务数据等扩展能力。 + Key 决定你能使用的数据范围。没有 Key 也能以 基础模式 + 使用历史日K;配置有效 Key 后可解锁实时行情、批量同步等扩展能力。

{/* 注册引导 */} @@ -339,6 +343,17 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () => {save.isError && (
保存失败:{String((save.error as any).message)}
)} + {/* 无效 key —— 探测失败(key 无效/乱填)未存储,提示用户 */} + {save.data && !save.data.ok && ( +
+ + + {save.data.reason === 'invalid' + ? 'Key 无效或已过期,请检查后重试(未保存该 Key)。' + : save.data.error ?? '保存失败'} + +
+ )} {/* 底部操作 */} @@ -384,7 +399,8 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void const settings = useSettings() const caps = useCapabilities() - const hasKey = settings.data?.has_tickflow_key + // 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置 + const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key' const capList = caps.data ? Object.entries(caps.data.capabilities) : [] return ( @@ -442,11 +458,10 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
-
将以 Free 模式继续
+
将以基础模式继续

- 你可以立即试用基础行情与选股功能。需要扩展数据时,随时在 - 设置 → 账户 - 配置 Key。 + 当前未配置有效 Key,仅可使用历史日K数据。配置 Key 后可解锁实时行情、批量同步等能力, + 随时在 设置 → 账户 填写。

)} diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 3d38018..2003d0a 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -10,6 +10,7 @@ import { type StrategyParamDef, } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { tierRank } from '@/lib/capability-labels' import { storage } from '@/lib/storage' import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format' import { boardTag } from '@/lib/board' @@ -620,7 +621,7 @@ export function StrategyBacktest() { // 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能 const [highGranularity, setHighGranularity] = useState(false) const { data: caps } = useCapabilities() - const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free') + const isFreeTier = tierRank(caps?.label ?? '') < 1 const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false) const [quickRanges, setQuickRanges] = useState(loadQuickRanges) const [settingsTab, setSettingsTab] = useState('params') diff --git a/frontend/src/pages/settings/Keys.tsx b/frontend/src/pages/settings/Keys.tsx index d2dda55..a4a1e1d 100644 --- a/frontend/src/pages/settings/Keys.tsx +++ b/frontend/src/pages/settings/Keys.tsx @@ -38,12 +38,15 @@ export function SettingsKeysPanel() { const save = useMutation({ mutationFn: () => api.saveTickflowKey(keyInput.trim()), - onSuccess: () => { + onSuccess: (data) => { setKeyInput('') - setSaved(true) qc.invalidateQueries({ queryKey: QK.settings }) qc.invalidateQueries({ queryKey: QK.capabilities }) - setTimeout(() => setSaved(false), 2000) + if (data.ok) { + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + // ok=false 由 save.data 在下方渲染提示(reason=invalid),无需额外处理 }, }) @@ -119,15 +122,21 @@ export function SettingsKeysPanel() { 已配置 {masked} + ) : mode === 'free' ? ( + <> + + 免费 Key + {masked} + ) : ( <> - - Free 试用 + + 未配置 · Free 数据 )}
- {mode === 'api_key' && ( + {(mode === 'api_key' || mode === 'free') && (