a45aa5b033
Co-Authored-By: Claude <noreply@anthropic.com>
773 lines
27 KiB
Python
773 lines
27 KiB
Python
"""设置 API — Key 配置 / 模式切换。
|
||
|
||
提供面向非开发者的 UI 配置入口,避免逼用户改 .env。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
|
||
from app import secrets_store
|
||
from app.tickflow import client as tf_client
|
||
from app.tickflow.policy import (
|
||
detect_capabilities,
|
||
extras_caps,
|
||
missing_caps,
|
||
probe_log,
|
||
tier_label,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||
|
||
# 默认端点 —— endpoints.json 列表第一项,UI"当前使用"始终对齐此项。
|
||
# 注意:Free 模式 SDK 实际走 free-api(免费数据通道),但 UI 显示统一用默认节点。
|
||
DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
|
||
|
||
|
||
def _sync_financial_scheduler_caps(app_state, capset) -> None:
|
||
"""把重新探测出的能力同步给财务调度器。
|
||
|
||
app.state.capabilities 在此已更新, 但 FinancialScheduler 在启动时捕获的是旧引用,
|
||
需显式刷新, 否则用户升级到 Expert 后点「全部同步」仍会因调度器读旧 capset 而被拒。
|
||
"""
|
||
fs = getattr(app_state, "financial_scheduler", None)
|
||
if fs is None:
|
||
return
|
||
try:
|
||
fs.update_capabilities(capset)
|
||
except Exception as e: # noqa: BLE001
|
||
logging.getLogger(__name__).warning("update financial_scheduler capabilities failed: %s", e)
|
||
|
||
|
||
class TickflowKeyIn(BaseModel):
|
||
api_key: str
|
||
|
||
|
||
@router.get("")
|
||
def get_settings() -> dict:
|
||
"""返回当前配置概况(Key 脱敏)。"""
|
||
from app.config import settings
|
||
from app.services import preferences
|
||
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
|
||
|
||
key = secrets_store.get_tickflow_key()
|
||
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
|
||
return {
|
||
"mode": tf_client.current_mode(),
|
||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||
"has_tickflow_key": bool(key),
|
||
"tier_label": tier_label(),
|
||
"current_endpoint": tf_client.current_endpoint(),
|
||
"probe_log": probe_log(),
|
||
"missing_caps": missing_caps(),
|
||
"extras_caps": extras_caps(),
|
||
# 首次使用引导
|
||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||
# AI 配置
|
||
"ai_provider": ai_provider,
|
||
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
|
||
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
|
||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||
"ai_configured": ai_configured(ai_provider),
|
||
"ai_model": current_ai_model(),
|
||
"ai_codex_command": current_codex_command(),
|
||
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
|
||
}
|
||
|
||
|
||
class SwitchEndpointIn(BaseModel):
|
||
url: str
|
||
|
||
|
||
@router.post("/switch_endpoint")
|
||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||
"""切换 TickFlow 端点并立即生效。
|
||
|
||
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
|
||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||
"""
|
||
# none/free 档没有付费端点权限,禁止切换
|
||
if tf_client.current_mode() != "api_key":
|
||
return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"}
|
||
|
||
url = req.url.strip().rstrip("/")
|
||
if not url.startswith("https://"):
|
||
return {"ok": False, "error": "仅支持 HTTPS 端点"}
|
||
|
||
# 持久化到 secrets.json
|
||
secrets_store.save({"tickflow_base_url": url})
|
||
# 重置客户端,下次调用自动用新端点
|
||
tf_client.reset_clients()
|
||
|
||
return {
|
||
"ok": True,
|
||
"current_endpoint": tf_client.current_endpoint(),
|
||
}
|
||
|
||
|
||
@router.post("/tickflow-key")
|
||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||
"""保存 TickFlow API Key 并立即重新探测能力。
|
||
|
||
先探后存(关键改动,修复乱填 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"}
|
||
|
||
# ===== 1) 临时存 key + 重置客户端,让探测走付费端点 =====
|
||
secrets_store.save({"tickflow_api_key": key})
|
||
tf_client.reset_clients()
|
||
|
||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||
capset = detect_capabilities(force=True)
|
||
request.app.state.capabilities = capset
|
||
_sync_financial_scheduler_caps(request.app.state, 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
|
||
_sync_financial_scheduler_caps(request.app.state, 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": [],
|
||
"capabilities_count": len(capset.all()),
|
||
}
|
||
|
||
|
||
@router.delete("/tickflow-key")
|
||
def clear_tickflow_key(request: Request) -> dict:
|
||
"""清除 Key,退回无档(none)。
|
||
|
||
同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api
|
||
服务器取历史日K;档位标签为 None(无档)。
|
||
"""
|
||
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
|
||
tf_client.reset_clients()
|
||
|
||
capset = detect_capabilities(force=True)
|
||
request.app.state.capabilities = capset
|
||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||
|
||
return {
|
||
"ok": True,
|
||
"mode": "none",
|
||
"tier_label": tier_label(),
|
||
"current_endpoint": tf_client.current_endpoint(),
|
||
"capabilities_count": len(capset.all()),
|
||
}
|
||
|
||
|
||
@router.post("/onboarding/complete")
|
||
def complete_onboarding() -> dict:
|
||
"""标记首次使用向导完成。
|
||
|
||
写入 preferences.json,前端守卫据此判断是否需要再次展示向导。
|
||
跨设备/清缓存安全 —— 状态落在后端文件,不依赖浏览器本地存储。
|
||
"""
|
||
from app.services import preferences
|
||
done = preferences.set_onboarding_completed(True)
|
||
return {"ok": True, "onboarding_completed": done}
|
||
|
||
|
||
class AiSettingsIn(BaseModel):
|
||
provider: str = "openai_compat"
|
||
base_url: str = ""
|
||
api_key: str | None = None
|
||
model: str = ""
|
||
codex_command: str = ""
|
||
user_agent: str = ""
|
||
|
||
|
||
@router.post("/ai")
|
||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||
from app.config import settings
|
||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
|
||
|
||
updates: dict = {}
|
||
if req.provider:
|
||
updates["ai_provider"] = req.provider
|
||
settings.ai_provider = req.provider
|
||
if req.base_url:
|
||
updates["ai_base_url"] = req.base_url
|
||
settings.ai_base_url = req.base_url
|
||
if req.api_key is not None:
|
||
if req.api_key:
|
||
updates["ai_api_key"] = req.api_key
|
||
settings.ai_api_key = req.api_key
|
||
else:
|
||
secrets_store.clear("ai_api_key")
|
||
settings.ai_api_key = ""
|
||
if req.provider == "codex_cli" and not req.model:
|
||
secrets_store.clear("ai_model")
|
||
settings.ai_model = ""
|
||
elif req.model:
|
||
updates["ai_model"] = req.model
|
||
settings.ai_model = req.model
|
||
if req.provider == "codex_cli":
|
||
try:
|
||
codex_command = normalize_codex_command(req.codex_command)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
updates["ai_codex_command"] = codex_command
|
||
settings.ai_codex_command = codex_command
|
||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||
updates["ai_user_agent"] = req.user_agent
|
||
settings.ai_user_agent = req.user_agent
|
||
|
||
if updates:
|
||
secrets_store.save(updates)
|
||
|
||
provider = current_ai_provider()
|
||
return {
|
||
"ok": True,
|
||
"ai_provider": provider,
|
||
"ai_model": current_ai_model(),
|
||
"ai_codex_command": current_codex_command(),
|
||
"ai_configured": ai_configured(provider),
|
||
}
|
||
|
||
|
||
@router.delete("/ai")
|
||
def clear_ai_settings() -> dict:
|
||
"""一键清空 AI 配置(provider / base_url / api_key / model)。
|
||
|
||
保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
|
||
"""
|
||
from app.config import settings
|
||
|
||
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
|
||
# 同步重置运行时内存(provider 回默认值,其余置空)
|
||
settings.ai_provider = "openai_compat"
|
||
settings.ai_base_url = ""
|
||
settings.ai_api_key = ""
|
||
settings.ai_model = ""
|
||
settings.ai_codex_command = "codex"
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
# ===== 偏好设置 =====
|
||
|
||
class MinuteSyncPrefs(BaseModel):
|
||
minute_sync_enabled: bool
|
||
minute_sync_days: int = 5
|
||
|
||
|
||
@router.get("/preferences")
|
||
def get_preferences() -> dict:
|
||
"""返回用户偏好设置。"""
|
||
from app.services import preferences
|
||
return {
|
||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||
"daily_data_provider": preferences.get_daily_data_provider(),
|
||
"adj_factor_provider": preferences.get_adj_factor_provider(),
|
||
"minute_data_provider": preferences.get_minute_data_provider(),
|
||
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
||
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
||
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
|
||
"pipeline_index_symbols": preferences.get_pipeline_index_symbols(),
|
||
"pipeline_schedule": preferences.get_pipeline_schedule(),
|
||
"instruments_schedule": preferences.get_instruments_schedule(),
|
||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
|
||
"screener_result_columns": preferences.get_screener_result_columns(),
|
||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||
"nav_order": preferences.get_nav_order(),
|
||
"nav_hidden": preferences.get_nav_hidden(),
|
||
"screener_auto_run": preferences.get_screener_auto_run(),
|
||
"review_schedule": preferences.get_review_schedule(),
|
||
"review_push_channels": preferences.get_review_push_channels(),
|
||
}
|
||
|
||
|
||
class NavOrderIn(BaseModel):
|
||
nav_order: list[str]
|
||
|
||
|
||
class NavHiddenIn(BaseModel):
|
||
nav_hidden: list[str]
|
||
|
||
|
||
@router.put("/preferences/nav-order")
|
||
def update_nav_order(req: NavOrderIn) -> dict:
|
||
"""保存左侧菜单排序(内置页面 path + 扩展分析菜单 id 的有序列表)。"""
|
||
from app.services import preferences
|
||
saved = preferences.set_nav_order(req.nav_order)
|
||
return {"nav_order": saved}
|
||
|
||
|
||
@router.put("/preferences/nav-hidden")
|
||
def update_nav_hidden(req: NavHiddenIn) -> dict:
|
||
"""保存左侧菜单隐藏项。"""
|
||
from app.services import preferences
|
||
saved = preferences.set_nav_hidden(req.nav_hidden)
|
||
return {"nav_hidden": saved}
|
||
|
||
|
||
@router.get("/preferences/screener-result-columns")
|
||
def get_screener_result_columns() -> dict:
|
||
"""返回策略结果列表列配置。"""
|
||
from app.services import preferences
|
||
cols = preferences.get_screener_result_columns()
|
||
return {"columns": cols}
|
||
|
||
|
||
@router.put("/preferences/screener-result-columns")
|
||
def update_screener_result_columns(req: dict) -> dict:
|
||
"""保存策略结果列表列配置。"""
|
||
from app.services import preferences
|
||
columns = req.get("columns", [])
|
||
saved = preferences.set_screener_result_columns(columns)
|
||
return {"columns": saved}
|
||
|
||
|
||
@router.put("/preferences/minute-sync")
|
||
def update_minute_sync(req: MinuteSyncPrefs) -> dict:
|
||
"""保存分钟 K 同步偏好。"""
|
||
from app.services import preferences
|
||
days = max(1, min(30, req.minute_sync_days))
|
||
preferences.save({
|
||
"minute_sync_enabled": req.minute_sync_enabled,
|
||
"minute_sync_days": days,
|
||
})
|
||
return {
|
||
"minute_sync_enabled": req.minute_sync_enabled,
|
||
"minute_sync_days": days,
|
||
}
|
||
|
||
|
||
class IndicesNavPinnedPrefs(BaseModel):
|
||
indices_nav_pinned: bool
|
||
|
||
|
||
@router.put("/preferences/indices-nav-pinned")
|
||
def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
|
||
"""保存侧栏指数报价卡片固定显示开关。
|
||
ON=常驻显示;OFF=跟随实时行情开关(仅实时开时显示)。"""
|
||
from app.services import preferences
|
||
preferences.save({"indices_nav_pinned": req.indices_nav_pinned})
|
||
return {"indices_nav_pinned": req.indices_nav_pinned}
|
||
|
||
|
||
class PipelinePullTypesIn(BaseModel):
|
||
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
|
||
pipeline_pull_a_share: bool | None = None
|
||
pipeline_pull_etf: bool | None = None
|
||
pipeline_pull_index: bool | None = None
|
||
|
||
|
||
@router.put("/preferences/pipeline-pull-types")
|
||
def update_pipeline_pull_types(req: PipelinePullTypesIn) -> dict:
|
||
"""更新盘后管道拉取内容开关。"""
|
||
from app.services import preferences
|
||
cfg = req.model_dump(exclude_none=True)
|
||
return preferences.set_pipeline_pull_types(cfg)
|
||
|
||
|
||
class PipelineIndexSymbolsIn(BaseModel):
|
||
"""指数自定义拉取代码(逗号/换行/空格分隔,空串表示全量)。"""
|
||
symbols: str = ""
|
||
|
||
|
||
@router.put("/preferences/pipeline-index-symbols")
|
||
def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
|
||
"""保存指数自定义拉取代码。"""
|
||
from app.services import preferences
|
||
symbols = preferences.set_pipeline_index_symbols(req.symbols)
|
||
return {"pipeline_index_symbols": symbols}
|
||
|
||
|
||
class TestEndpointIn(BaseModel):
|
||
url: str
|
||
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
|
||
rounds: int | None = None
|
||
|
||
|
||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json
|
||
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
|
||
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
|
||
ENDPOINTS_TTL = 300.0 # 秒
|
||
|
||
# 回退列表 —— 与官方 endpoints.json 的 endpoints[] 字段对齐。
|
||
# 当远程拉取失败时使用,保证 UI 永远有内容可显示。
|
||
_FALLBACK_ENDPOINTS: list[dict] = [
|
||
{
|
||
"id": "default",
|
||
"url": "https://api.tickflow.org",
|
||
"label": "默认端点",
|
||
"region": "auto",
|
||
"description": "默认端点",
|
||
"premium": False,
|
||
},
|
||
{
|
||
"id": "hk",
|
||
"url": "https://hk-api.tickflow.org",
|
||
"label": "香港端点",
|
||
"region": "ap-east-1",
|
||
"description": "备用端点,部分地区访问更稳定",
|
||
"premium": False,
|
||
},
|
||
{
|
||
"id": "sg",
|
||
"url": "https://sg-api.tickflow.org",
|
||
"label": "新加坡端点",
|
||
"region": "ap-southeast-1",
|
||
"description": "备用端点,亚太地区访问更稳定",
|
||
"premium": False,
|
||
},
|
||
{
|
||
"id": "us",
|
||
"url": "https://us-api.tickflow.org",
|
||
"label": "美国端点",
|
||
"region": "us-east-1",
|
||
"description": "备用端点,欧美地区访问更稳定",
|
||
"premium": False,
|
||
},
|
||
{
|
||
"id": "cn",
|
||
"url": "https://139.196.55.234:50443",
|
||
"label": "中国大陆端点(Beta)",
|
||
"region": "cn-east-1",
|
||
"description": "备用端点,中国大陆地区访问更稳定,目前处于测试阶段,谨慎使用",
|
||
"premium": False,
|
||
},
|
||
{
|
||
"id": "cn-premium",
|
||
"url": "https://106.15.238.72:50443",
|
||
"label": "中国大陆专线端点",
|
||
"region": "cn-east-1",
|
||
"description": "专线加速端点,需要专线加速权限(该权限包含在 Expert 及以上套餐中,也可通过自定义组合单独开通)",
|
||
"premium": True,
|
||
},
|
||
]
|
||
|
||
# 进程内缓存:{ "ts": float, "data": dict }
|
||
_endpoints_cache: dict = {"ts": 0.0, "data": None}
|
||
|
||
|
||
@router.get("/endpoints")
|
||
def list_endpoints() -> dict:
|
||
"""代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。
|
||
|
||
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
|
||
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
|
||
返回结构与原始 endpoints.json 一致(透传 schema/version 等元信息)。
|
||
"""
|
||
import httpx
|
||
|
||
now = time.monotonic()
|
||
cached = _endpoints_cache.get("data")
|
||
if cached is not None and (now - _endpoints_cache["ts"]) < ENDPOINTS_TTL:
|
||
return cached
|
||
|
||
source = "remote"
|
||
data: dict | None = None
|
||
try:
|
||
resp = httpx.get(ENDPOINTS_URL, timeout=8.0, follow_redirects=True)
|
||
if resp.status_code == 200:
|
||
parsed = resp.json()
|
||
eps = parsed.get("endpoints")
|
||
# 校验:必须是列表且每项含必要字段,否则视为无效
|
||
if isinstance(eps, list) and all(
|
||
isinstance(e, dict) and "url" in e for e in eps
|
||
):
|
||
data = {
|
||
"version": parsed.get("version", 1),
|
||
"description": parsed.get(
|
||
"description", "TickFlow API 端点配置"
|
||
),
|
||
"healthPath": parsed.get("healthPath", "/health"),
|
||
"testRounds": parsed.get("testRounds", 5),
|
||
"endpoints": eps,
|
||
}
|
||
except (httpx.HTTPError, ValueError):
|
||
logger.warning("拉取 endpoints.json 失败,使用内置回退列表", exc_info=True)
|
||
|
||
if data is None:
|
||
source = "fallback"
|
||
data = {
|
||
"version": 1,
|
||
"description": "TickFlow API 端点配置",
|
||
"healthPath": "/health",
|
||
"testRounds": 5,
|
||
"endpoints": _FALLBACK_ENDPOINTS,
|
||
}
|
||
|
||
# 标记数据来源,便于前端提示(回退时显示"内置列表")。
|
||
data["source"] = source
|
||
_endpoints_cache["ts"] = now
|
||
_endpoints_cache["data"] = data
|
||
return data
|
||
|
||
|
||
async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
|
||
"""单次异步 GET 请求并返回延迟(ms),失败返回 None。
|
||
|
||
对齐官方 latency_test.py:用 /health 轻量端点测真实网络延迟,
|
||
不携带 API Key(/health 公开)。异步实现,保证多端点并行测速不阻塞。
|
||
"""
|
||
import httpx
|
||
|
||
t0 = time.perf_counter()
|
||
try:
|
||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||
resp = await client.get(url)
|
||
dt = (time.perf_counter() - t0) * 1000
|
||
# 只把 <400 视为成功;4xx/5xx 也算"不可达"
|
||
if resp.status_code < 400:
|
||
return round(dt, 2)
|
||
return None
|
||
except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError, OSError):
|
||
return None
|
||
|
||
|
||
@router.post("/test_endpoint")
|
||
async def test_endpoint(req: TestEndpointIn) -> dict:
|
||
"""测试端点网络延迟:对 /health 多轮探测取中位数。
|
||
|
||
参考 TickFlow 官方 latency_test.py:
|
||
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
|
||
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
|
||
- 返回 median/min/max/success,前端显示中位数
|
||
- 异步实现,保证"全部测速"时多端点真正并行
|
||
"""
|
||
import asyncio
|
||
import statistics
|
||
|
||
base = req.url.rstrip("/")
|
||
rounds = max(1, min(10, req.rounds or _endpoints_cache.get("data", {}).get("testRounds", 5)))
|
||
health_url = base + "/health"
|
||
|
||
latencies: list[float] = []
|
||
for _ in range(rounds):
|
||
ms = await _http_ping(health_url)
|
||
if ms is not None:
|
||
latencies.append(ms)
|
||
# 官方脚本间隔 0.3s;末轮无需等待
|
||
await asyncio.sleep(0.3)
|
||
|
||
success = len(latencies)
|
||
if success == 0:
|
||
return {
|
||
"ok": False,
|
||
"error": "不可达",
|
||
"url": req.url,
|
||
"rounds": rounds,
|
||
"success": 0,
|
||
"median_ms": None,
|
||
"min_ms": None,
|
||
"max_ms": None,
|
||
}
|
||
|
||
median = round(statistics.median(latencies), 2)
|
||
return {
|
||
"ok": True,
|
||
"url": req.url,
|
||
"rounds": rounds,
|
||
"success": success,
|
||
"median_ms": median,
|
||
"min_ms": round(min(latencies), 2),
|
||
"max_ms": round(max(latencies), 2),
|
||
# 兼容旧字段:取中位数作为代表延迟
|
||
"latency_ms": median,
|
||
}
|
||
|
||
|
||
class PipelineScheduleIn(BaseModel):
|
||
hour: int
|
||
minute: int
|
||
|
||
|
||
@router.put("/preferences/pipeline-schedule")
|
||
def update_pipeline_schedule(req: PipelineScheduleIn, request: Request) -> dict:
|
||
"""保存盘后管道调度时间并立即 reschedule。"""
|
||
from app.services import preferences
|
||
sched = preferences.set_pipeline_schedule(req.hour, req.minute)
|
||
|
||
# 动态 reschedule
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
scheduler = getattr(request.app.state, "scheduler", None)
|
||
if scheduler:
|
||
scheduler.reschedule_job(
|
||
"daily_pipeline",
|
||
trigger=CronTrigger(
|
||
day_of_week="mon-fri",
|
||
hour=sched["hour"],
|
||
minute=sched["minute"],
|
||
timezone="Asia/Shanghai",
|
||
),
|
||
)
|
||
logger.info("pipeline rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||
|
||
return sched
|
||
|
||
|
||
@router.put("/preferences/instruments-schedule")
|
||
def update_instruments_schedule(req: PipelineScheduleIn, request: Request) -> dict:
|
||
"""保存盘前标的维表调度时间并立即 reschedule。"""
|
||
from app.services import preferences
|
||
sched = preferences.set_instruments_schedule(req.hour, req.minute)
|
||
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
scheduler = getattr(request.app.state, "scheduler", None)
|
||
if scheduler:
|
||
scheduler.reschedule_job(
|
||
"pre_market_instruments",
|
||
trigger=CronTrigger(
|
||
day_of_week="mon-fri",
|
||
hour=sched["hour"],
|
||
minute=sched["minute"],
|
||
timezone="Asia/Shanghai",
|
||
),
|
||
)
|
||
return sched
|
||
|
||
|
||
class EnrichedBatchSizeIn(BaseModel):
|
||
size: int
|
||
|
||
|
||
@router.put("/preferences/enriched-batch-size")
|
||
def update_enriched_batch_size(req: EnrichedBatchSizeIn) -> dict:
|
||
"""保存 enriched 全量计算批次大小。"""
|
||
from app.services import preferences
|
||
size = preferences.set_enriched_batch_size(req.size)
|
||
return {"enriched_batch_size": size}
|
||
|
||
|
||
class IndexDailyBatchSizeIn(BaseModel):
|
||
size: int
|
||
|
||
|
||
@router.put("/preferences/index-daily-batch-size")
|
||
def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
|
||
"""保存指数日 K 同步批次大小。"""
|
||
from app.services import preferences
|
||
size = preferences.set_index_daily_batch_size(req.size)
|
||
return {"index_daily_batch_size": size}
|
||
|
||
|
||
class ReviewScheduleIn(BaseModel):
|
||
enabled: bool
|
||
hour: int
|
||
minute: int
|
||
|
||
|
||
@router.put("/preferences/review-schedule")
|
||
def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict:
|
||
"""保存定时复盘调度并立即更新 APScheduler job。
|
||
|
||
- enabled=True: 注册/更新 job(工作日定时生成复盘报告)
|
||
- enabled=False: 移除 job(停止定时复盘)
|
||
- 校验: 开启时若 AI Key 未配置则拒绝(复盘依赖 AI), 提示用户先配置。
|
||
- 时间下限 15:00(A股收盘), 由 preferences 层强制。
|
||
"""
|
||
from app.services import preferences
|
||
|
||
if req.enabled:
|
||
# 复盘必须有 AI Key, 否则每日报错刷日志
|
||
from app import secrets_store
|
||
if not secrets_store.get_ai_key():
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="复盘依赖 AI,请先在「设置 → AI」配置 API Key 后再开启定时复盘",
|
||
)
|
||
|
||
sched = preferences.set_review_schedule(req.enabled, req.hour, req.minute)
|
||
|
||
# 动态操作 APScheduler job
|
||
from app.jobs.daily_pipeline import _register_review_job, REVIEW_JOB_ID
|
||
scheduler = getattr(request.app.state, "scheduler", None)
|
||
if scheduler:
|
||
if sched["enabled"]:
|
||
_register_review_job(scheduler, request.app.state.repo, sched["hour"], sched["minute"])
|
||
logger.info("scheduled_review enabled @%02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||
else:
|
||
try:
|
||
scheduler.remove_job(REVIEW_JOB_ID)
|
||
logger.info("scheduled_review disabled (job removed)")
|
||
except Exception:
|
||
pass # job 本就不存在(从未开过), 无需处理
|
||
|
||
return sched
|
||
|
||
|
||
class ReviewPushIn(BaseModel):
|
||
channels: list[str] # 多选: ['feishu'] 等; 空数组=不推送。微信等开发中
|
||
|
||
|
||
@router.put("/preferences/review-push")
|
||
def update_review_push(req: ReviewPushIn) -> dict:
|
||
"""复盘推送渠道(多选) — 选定把复盘报告(手动生成 / 定时生成归档后)推送到哪些外部工具。
|
||
|
||
纯偏好, 与定时复盘 / 实时行情完全独立, 常驻可单独设置。空数组=不推送。
|
||
实际推送由归档端点(POST /api/market-recap/reports)与定时任务(_run_scheduled_review)
|
||
在归档后读取本列表逐个推送。白名单外的渠道会被过滤掉。
|
||
"""
|
||
from app.services import preferences
|
||
saved = preferences.set_review_push_channels(req.channels)
|
||
return {"review_push_channels": saved}
|
||
|