Files
stock/serve/backend/app/api/settings.py
T

811 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设置 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
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
"""读 AI 配置: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro。"""
if username:
val = secrets_store.load_ai_config(username).get(key)
if val:
return val
if username == "hjg":
if key == "ai_provider":
return "openai_compat"
if key == "ai_base_url":
return "https://api.deepseek.com/v1"
if key == "ai_model":
return "deepseek-chat"
if key == "ai_user_agent":
return (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
return secrets_store.get_ai_config(key, default)
def _get_ai_api_key(username: str | None) -> str:
"""读 AI API Key: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro Key。"""
if username:
val = secrets_store.load_ai_config(username).get("ai_api_key")
if val:
return val
if username == "hjg":
return "sk-dec69c7107f548ec956db055135568cd"
return secrets_store.get_ai_key()
@router.get("")
def get_settings(request: Request) -> 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
username = getattr(request.state, "username", None)
key = secrets_store.get_tickflow_key()
ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider)
ai_api_key = _get_ai_api_key(username)
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": _get_ai_config(username, "ai_base_url", settings.ai_base_url),
"ai_api_key_masked": secrets_store.mask(ai_api_key),
"has_ai_key": bool(ai_api_key),
"ai_configured": ai_configured(ai_provider),
"ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(),
"ai_user_agent": _get_ai_config(username, "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, request: Request) -> dict:
"""保存 AI 配置(按用户独立存储)。"""
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
username = getattr(request.state, "username", None)
target = username if username else "global"
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_config(target, "ai_api_key")
settings.ai_api_key = ""
if req.provider == "codex_cli" and not req.model:
secrets_store.clear_ai_config(target, "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 允许清空,故无条件持久化
updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent
if updates:
secrets_store.save_ai_config(target, 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(request: Request) -> dict:
"""一键清空当前用户的 AI 配置。"""
from app.config import settings
username = getattr(request.state, "username", None)
target = username if username else "global"
secrets_store.clear_ai_config(target, "ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
# 同步重置运行时内存
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}