重置项目
This commit is contained in:
@@ -7,11 +7,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import secrets_store
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import (
|
||||
detect_capabilities,
|
||||
@@ -30,25 +29,34 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
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 _sync_financial_scheduler(request: Request, capset) -> None:
|
||||
"""Key 变更后同步财务调度器状态,无需重启服务。"""
|
||||
try:
|
||||
financial_scheduler.update(request.app.state.repo.store.data_dir, capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("financial_scheduler update failed: %s", e)
|
||||
|
||||
|
||||
@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),
|
||||
@@ -61,12 +69,14 @@ def get_settings() -> dict:
|
||||
# 首次使用引导
|
||||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||||
# AI 配置
|
||||
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
|
||||
"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_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
|
||||
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +86,9 @@ class SwitchEndpointIn(BaseModel):
|
||||
|
||||
@router.post("/switch_endpoint")
|
||||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
"""切换数据源端点并立即生效。
|
||||
"""切换 TickFlow 端点并立即生效。
|
||||
|
||||
端点切换仅对付费档(starter+,走付费 API 节点)有意义;
|
||||
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
|
||||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||||
"""
|
||||
# none/free 档没有付费端点权限,禁止切换
|
||||
@@ -102,7 +112,7 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
|
||||
@router.post("/tickflow-key")
|
||||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
"""保存数据源 API Key 并立即重新探测能力。
|
||||
"""保存 TickFlow API Key 并立即重新探测能力。
|
||||
|
||||
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
|
||||
1. 临时用新 key 探测(付费端点),判定档位
|
||||
@@ -112,7 +122,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
|
||||
|
||||
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
|
||||
故自动切到默认付费端点;free 档则清除自定义端点。
|
||||
故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。
|
||||
"""
|
||||
from app.tickflow.policy import (
|
||||
base_tier_name, is_invalid_key,
|
||||
@@ -129,7 +139,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
|
||||
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
|
||||
if is_invalid_key() or base_tier_name() == "none":
|
||||
@@ -138,7 +148,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
tf_client.reset_clients()
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "invalid",
|
||||
@@ -195,7 +205,7 @@ def clear_tickflow_key(request: Request) -> dict:
|
||||
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -223,13 +233,15 @@ class AiSettingsIn(BaseModel):
|
||||
base_url: str = ""
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
daily_token_budget: int = 500_000
|
||||
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:
|
||||
@@ -245,15 +257,52 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.model:
|
||||
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
|
||||
updates["ai_daily_token_budget"] = req.daily_token_budget
|
||||
settings.ai_daily_token_budget = req.daily_token_budget
|
||||
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}
|
||||
|
||||
|
||||
@@ -280,6 +329,16 @@ def get_preferences() -> dict:
|
||||
"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(),
|
||||
"realtime_data_provider": preferences.get_realtime_data_provider(),
|
||||
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
|
||||
**preferences.get_realtime_quote_scope(),
|
||||
"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(),
|
||||
@@ -290,6 +349,9 @@ def get_preferences() -> dict:
|
||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
|
||||
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
|
||||
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
@@ -297,6 +359,8 @@ def get_preferences() -> dict:
|
||||
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
|
||||
"depth_polling_interval": preferences.get_depth_polling_interval(),
|
||||
"depth_finalize_time": preferences.get_depth_finalize_time(),
|
||||
"review_schedule": preferences.get_review_schedule(),
|
||||
"review_push_channels": preferences.get_review_push_channels(),
|
||||
}
|
||||
|
||||
|
||||
@@ -377,11 +441,19 @@ class RealtimeQuotesPrefs(BaseModel):
|
||||
realtime_quotes_enabled: bool
|
||||
|
||||
|
||||
class RealtimeQuoteScopePrefs(BaseModel):
|
||||
realtime_pull_stock: bool | None = None
|
||||
realtime_pull_etf: bool | None = None
|
||||
realtime_pull_index: bool | None = None
|
||||
realtime_index_mode: str | None = None
|
||||
realtime_index_symbols: list[str] | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quotes")
|
||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""保存全局实时行情开关。
|
||||
|
||||
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
|
||||
none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。
|
||||
前端据此把开关置灰 / 回弹。
|
||||
"""
|
||||
from app.services import preferences
|
||||
@@ -394,6 +466,9 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
if qs:
|
||||
qs.disable()
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
||||
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
|
||||
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
if qs:
|
||||
@@ -405,6 +480,26 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quote-scope")
|
||||
def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
|
||||
"""保存盘中实时行情范围;独立于盘后管道范围。"""
|
||||
from app.services import preferences
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
return preferences.set_realtime_quote_scope(cfg)
|
||||
|
||||
|
||||
class RealtimeWatchlistPrefs(BaseModel):
|
||||
symbols: list[str] = []
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-watchlist")
|
||||
def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict:
|
||||
"""兼容旧入口;Free 实时标的由自选页前 5 个决定。"""
|
||||
from app.services import preferences
|
||||
symbols = preferences.set_realtime_watchlist_symbols(req.symbols)
|
||||
return {"realtime_watchlist_symbols": symbols}
|
||||
|
||||
|
||||
class IndicesNavPinnedPrefs(BaseModel):
|
||||
indices_nav_pinned: bool
|
||||
|
||||
@@ -457,6 +552,34 @@ def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Reques
|
||||
return result
|
||||
|
||||
|
||||
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 QuoteIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
@@ -477,6 +600,50 @@ def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
||||
return {"system_notify_enabled": saved}
|
||||
|
||||
|
||||
class FeishuWebhookPrefsIn(BaseModel):
|
||||
url: str
|
||||
secret: str = ""
|
||||
|
||||
|
||||
@router.put("/preferences/feishu-webhook")
|
||||
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
|
||||
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
|
||||
|
||||
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
|
||||
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
|
||||
"""
|
||||
from app.services import preferences
|
||||
from app.services import webhook_adapter
|
||||
|
||||
url = (req.url or "").strip()
|
||||
if url and not webhook_adapter.is_valid_feishu_url(url):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
|
||||
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
|
||||
)
|
||||
saved_url = preferences.set_feishu_webhook_url(url)
|
||||
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
|
||||
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
|
||||
|
||||
|
||||
class WebhookEnabledDefaultIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/webhook-enabled-default")
|
||||
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
|
||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
||||
|
||||
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
|
||||
单条规则仍可在规则编辑页独立修改此项。
|
||||
"""
|
||||
from app.services import preferences
|
||||
|
||||
saved = preferences.set_webhook_enabled_default(req.enabled)
|
||||
return {"webhook_enabled_default": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/quote-interval")
|
||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
||||
@@ -510,7 +677,7 @@ class TestEndpointIn(BaseModel):
|
||||
rounds: int | None = None
|
||||
|
||||
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取数据源官网 /endpoints.json
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json
|
||||
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
|
||||
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
|
||||
ENDPOINTS_TTL = 300.0 # 秒
|
||||
@@ -574,7 +741,7 @@ _endpoints_cache: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
@router.get("/endpoints")
|
||||
def list_endpoints() -> dict:
|
||||
"""代理拉取数据源官网 /endpoints.json 并返回规范化端点列表。
|
||||
"""代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。
|
||||
|
||||
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
|
||||
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
|
||||
@@ -601,7 +768,7 @@ def list_endpoints() -> dict:
|
||||
data = {
|
||||
"version": parsed.get("version", 1),
|
||||
"description": parsed.get(
|
||||
"description", "API 端点配置"
|
||||
"description", "TickFlow API 端点配置"
|
||||
),
|
||||
"healthPath": parsed.get("healthPath", "/health"),
|
||||
"testRounds": parsed.get("testRounds", 5),
|
||||
@@ -614,7 +781,7 @@ def list_endpoints() -> dict:
|
||||
source = "fallback"
|
||||
data = {
|
||||
"version": 1,
|
||||
"description": "API 端点配置",
|
||||
"description": "TickFlow API 端点配置",
|
||||
"healthPath": "/health",
|
||||
"testRounds": 5,
|
||||
"endpoints": _FALLBACK_ENDPOINTS,
|
||||
@@ -652,7 +819,7 @@ async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
|
||||
async def test_endpoint(req: TestEndpointIn) -> dict:
|
||||
"""测试端点网络延迟:对 /health 多轮探测取中位数。
|
||||
|
||||
参考官方 latency_test.py:
|
||||
参考 TickFlow 官方 latency_test.py:
|
||||
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
|
||||
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
|
||||
- 返回 median/min/max/success,前端显示中位数
|
||||
@@ -852,3 +1019,65 @@ def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> di
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user