580743853a
serve 端不再启动 pull_scheduler 和 financial_scheduler, 阻塞 ext_data 和 financials 的外部获取/同步 API 端点, 数据仅通过 local→serve 同步推送。 Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
8.2 KiB
Python
219 lines
8.2 KiB
Python
"""FastAPI 入口。"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from app import __version__
|
|
from app.api import analysis, auth as auth_api, data, data_sync, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
|
|
from app.api.routes import router as core_router
|
|
from app.config import settings
|
|
from app.jobs import daily_pipeline
|
|
from app.tickflow import client as tf_client
|
|
from app.tickflow.policy import detect_capabilities
|
|
from app.tickflow.repository import DataStore, KlineRepository
|
|
|
|
logging.basicConfig(
|
|
level=settings.log_level,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info(
|
|
"TickFlow Stock Panel v%s starting (mode=%s)",
|
|
__version__, tf_client.current_mode(),
|
|
)
|
|
|
|
# 首次启动: 若配置了 AUTH_PASSWORD 环境变量且未设过密码, 用它初始化。
|
|
# 公网部署免 SSH 端口转发; 已设过密码则不覆盖 (改密码走 UI)。
|
|
try:
|
|
from app.services import auth as auth_service
|
|
auth_service.bootstrap_from_env()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("auth bootstrap failed: %s", e)
|
|
|
|
# 数据层
|
|
store = DataStore()
|
|
repo = KlineRepository(store)
|
|
app.state.datastore = store
|
|
app.state.repo = repo
|
|
|
|
# Polars 缓存预热
|
|
repo.refresh_cache()
|
|
|
|
# 能力探测
|
|
capset = detect_capabilities()
|
|
app.state.capabilities = capset
|
|
logger.info("ready; %d capabilities active", len(capset.all()))
|
|
|
|
# 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
|
|
try:
|
|
scheduler = daily_pipeline.start_scheduler(repo, capset)
|
|
app.state.scheduler = scheduler
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("scheduler not started: %s", e)
|
|
app.state.scheduler = None
|
|
|
|
# 内置扩展表 (概念/行业): 只创建 config 供 sync 解压时用, 不拉数据。
|
|
try:
|
|
from app.services.ext_presets import ensure_builtin_presets
|
|
await ensure_builtin_presets(store.data_dir)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
|
|
|
|
# 策略引擎
|
|
from app.strategy.engine import StrategyEngine
|
|
from app.services.screener import ScreenerService
|
|
|
|
_screener_svc = ScreenerService(repo)
|
|
strategy_dirs = [
|
|
Path(__file__).resolve().parent / "strategy" / "builtin",
|
|
store.data_dir / "strategies" / "custom",
|
|
store.data_dir / "strategies" / "ai",
|
|
]
|
|
strategy_engine = StrategyEngine(
|
|
enriched_loader=_screener_svc._load_enriched_for_date,
|
|
enriched_history_loader=_screener_svc._load_enriched_history,
|
|
strategy_dirs=strategy_dirs,
|
|
)
|
|
app.state.strategy_engine = strategy_engine
|
|
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
|
|
|
|
yield
|
|
|
|
if app.state.scheduler:
|
|
app.state.scheduler.shutdown(wait=False)
|
|
logger.info("shutdown")
|
|
|
|
|
|
app = FastAPI(
|
|
title="TickFlow Stock Panel",
|
|
version=__version__,
|
|
description="A 股选股 + 回测面板 — TickFlow 适配",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# CORS: 允许局域网访问 (自托管场景, 放开所有来源)
|
|
# 注: allow_credentials=True 与 allow_origins=['*'] 不能共存 (浏览器规范),
|
|
# 本项目认证走 header (API Key), 不依赖 cookie, 故关闭 credentials 换取通配来源。
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ================================================================
|
|
# 访问认证中间件
|
|
# ================================================================
|
|
# 拦截所有 /api/ 请求, 三种状态:
|
|
# 1. 未设密码 + 本机/内网 → 放行(让本机用户访问面板 + 调 /api/auth/setup 设密码)
|
|
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
|
|
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
|
|
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
|
|
_AUTH_WHITELIST_PREFIX = ("/api/auth/", "/api/data/sync/")
|
|
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def auth_middleware(request: Request, call_next):
|
|
path = request.url.path
|
|
# 仅 /api/ 走认证; 静态资源(前端页面/assets)放行, 由前端处理跳转
|
|
if not path.startswith("/api/"):
|
|
return await call_next(request)
|
|
# 白名单放行(设密码/登录/探活本身不拦)
|
|
if path.startswith(_AUTH_WHITELIST_PREFIX) or path in _AUTH_WHITELIST_EXACT:
|
|
return await call_next(request)
|
|
|
|
from app.services import auth as auth_service
|
|
# 情况 1+2: 未设密码
|
|
if not auth_service.is_configured():
|
|
# 本机/内网 → 放行(服务器主人可访问, 并去 /login 设密码)
|
|
if auth_api._is_local_network(auth_api._client_ip(request)):
|
|
return await call_next(request)
|
|
# 公网 → 拒绝。不裸奔, 也不给公网设密码的机会(防抢占)
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={
|
|
"detail": "面板尚未初始化访问密码,请通过 SSH/本机浏览器访问以设置密码",
|
|
"code": "NOT_INITIALIZED",
|
|
},
|
|
)
|
|
|
|
# 情况 3: 已设密码, 检查会话
|
|
token = request.cookies.get(auth_api.COOKIE_NAME)
|
|
if token and auth_service.is_valid_session(token):
|
|
return await call_next(request)
|
|
# 未登录: 401(前端跳登录页)
|
|
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
|
|
|
|
|
|
# 路由
|
|
app.include_router(core_router)
|
|
app.include_router(auth_api.router)
|
|
app.include_router(kline.router)
|
|
app.include_router(screener.router)
|
|
app.include_router(indices.router)
|
|
app.include_router(overview.router)
|
|
app.include_router(analysis.router)
|
|
app.include_router(pipeline.router)
|
|
app.include_router(data.router)
|
|
app.include_router(data_sync.router)
|
|
app.include_router(ext_data.router)
|
|
app.include_router(financials.router)
|
|
app.include_router(stock_analysis.router)
|
|
app.include_router(market_recap.router)
|
|
app.include_router(settings_api.router)
|
|
app.include_router(strategy.router)
|
|
app.include_router(signals.router)
|
|
app.include_router(alerts.router)
|
|
app.include_router(rps.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():
|
|
if (_static / "assets").exists():
|
|
app.mount("/assets", StaticFiles(directory=_static / "assets"), name="assets")
|
|
|
|
@app.get("/{full_path:path}", include_in_schema=False)
|
|
def spa_fallback(full_path: str): # noqa: ARG001
|
|
"""所有未匹配路径回退到 index.html — React Router 接管。
|
|
|
|
index.html 禁止缓存 (Cache-Control: no-store), 确保浏览器每次拿到
|
|
最新版本引用的 JS/CSS 文件名 (assets 带 hash, 可长缓存)。
|
|
"""
|
|
index = _static / "index.html"
|
|
if index.exists():
|
|
return FileResponse(
|
|
index,
|
|
headers={"Cache-Control": "no-store, must-revalidate"},
|
|
)
|
|
return {"error": "frontend not built"}
|